diff --git a/app/build.gradle b/app/build.gradle index 2a58130..aba2c48 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -43,28 +43,38 @@ dependencies { compile 'com.android.support:cardview-v7:25.4.0' compile 'com.android.support.constraint:constraint-layout:1.0.2' compile 'me.dm7.barcodescanner:zxing:1.9.8' + + compile "com.squareup.okhttp3:okhttp:$rootProject.ext.okHttpVersion" + + testCompile "junit:junit:$rootProject.ext.junitVersion" + testCompile "org.mockito:mockito-all:$rootProject.ext.mockitoVersion" + testCompile "com.squareup.okhttp3:mockwebserver:$rootProject.ext.okHttpVersion" + testCompile 'org.json:json:20140107' + testCompile 'net.jodah:concurrentunit:0.4.2' } dependencyVerification { verify = [ - 'com.android.support:appcompat-v7:70551e62660db15b790c5275f56b9de4dd9407d1494d07c8f3dd5698f3638677', 'com.android.support:design:3f409bf2019967ffc344cfaf11e52131fac982468a1707aaeb25bf3c52838966', + 'com.android.support:appcompat-v7:70551e62660db15b790c5275f56b9de4dd9407d1494d07c8f3dd5698f3638677', + 'com.android.support:transition:848270144fb180efd2bf928a00ed176dbbc5290badfd638390ffba90088df8b3', + 'me.dm7.barcodescanner:zxing:d43973c9527c23fa8e6d338c6a2c458e373ce1ac6bcaa3bc41d11ae49116000d', + 'me.dm7.barcodescanner:core:a5c8a704089b58029db166172ed8e55d756877d010a85a0b1c94fdc96ffb8f9a', 'com.android.support:support-v4:ee44c481a1f4d6978568e223e8125379b52b2ececdd53450e09ebae144bd377d', 'com.android.support:recyclerview-v7:a2fe121f9d01ed8980e97095b4a3fe9700a0aa0a7d4b0f8c594f765ad8455a0d', 'com.android.support:cardview-v7:f3fbbe1fcfdbec7333c6a2c516c5fd511a909d1975271818e268d6fe297d8c70', 'com.android.support.constraint:constraint-layout:b0c688cc2b7172608f8153a689d746da40f71e52d7e2fe2bfd9df2f92db77085', - 'me.dm7.barcodescanner:zxing:d43973c9527c23fa8e6d338c6a2c458e373ce1ac6bcaa3bc41d11ae49116000d', - 'com.android.support:support-annotations:a774272036941b4e912eb426d70c848bde7f06a3bf5fb491f75a427dc6595270', - 'com.android.support:support-vector-drawable:077009d13882ee96f061e4bc2dbe7cce7ae1762d8297592a787ff741afbfb1f2', 'com.android.support:animated-vector-drawable:628ab1d56a6ee4cbedf32617af8b2a1fe02964ed0628e8f898cc09ddba6e1835', - 'com.android.support:transition:848270144fb180efd2bf928a00ed176dbbc5290badfd638390ffba90088df8b3', - 'com.android.support:support-compat:54019c63614ce08b02d7b9605490cd2b29ba5b2505f394a9517450b5f72b30ca', + 'com.android.support:support-vector-drawable:077009d13882ee96f061e4bc2dbe7cce7ae1762d8297592a787ff741afbfb1f2', + 'com.android.support:support-fragment:316d35d4d2d2902057efad104a73e4bdb50bee260a7075678185b8cd71170945', + 'com.android.support:support-core-ui:e72ae29b823889686cff6fcb948d6745c2baf6d4c2af4fdffa1ec1e42e3833a3', 'com.android.support:support-media-compat:566a161d9cb0083ef62a53e46b71ce5b3d455b8635b1a0a4ae28d96d4b583de8', 'com.android.support:support-core-utils:34b8437dfa95ff28d29cf57ffa3b1354a9fa9bfe4059f0fd5ce2f5e4326a1748', - 'com.android.support:support-core-ui:e72ae29b823889686cff6fcb948d6745c2baf6d4c2af4fdffa1ec1e42e3833a3', - 'com.android.support:support-fragment:316d35d4d2d2902057efad104a73e4bdb50bee260a7075678185b8cd71170945', + 'com.android.support:support-compat:54019c63614ce08b02d7b9605490cd2b29ba5b2505f394a9517450b5f72b30ca', + 'com.android.support:support-annotations:a774272036941b4e912eb426d70c848bde7f06a3bf5fb491f75a427dc6595270', 'com.android.support.constraint:constraint-layout-solver:8c62525a9bc5cff5633a96cb9b32fffeccaf41b8841aa87fc22607070dea9b8d', - 'me.dm7.barcodescanner:core:a5c8a704089b58029db166172ed8e55d756877d010a85a0b1c94fdc96ffb8f9a', 'com.google.zxing:core:bba7724e02a997cec38213af77133ee8e24b0d5cf5fa7ecbc16a4fa93f11ee0d', + 'com.squareup.okio:okio:734269c3ebc5090e3b23566db558f421f0b4027277c79ad5d176b8ec168bb850', + 'com.squareup.okhttp3:okhttp:7265adbd6f028aade307f58569d814835cd02bc9beffb70c25f72c9de50d61c4', ] } diff --git a/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java index 8da4ede..7a01c68 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java +++ b/app/src/main/java/com/m2049r/xmrwallet/WalletFragment.java @@ -18,6 +18,8 @@ package com.m2049r.xmrwallet; import android.content.Context; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v7.widget.RecyclerView; @@ -36,19 +38,23 @@ import android.widget.ProgressBar; import android.widget.Spinner; import android.widget.TextView; -import com.m2049r.xmrwallet.layout.AsyncExchangeRate; import com.m2049r.xmrwallet.layout.Toolbar; import com.m2049r.xmrwallet.layout.TransactionInfoAdapter; import com.m2049r.xmrwallet.model.TransactionInfo; 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.service.exchange.kraken.ExchangeApiImpl; import com.m2049r.xmrwallet.util.Helper; import java.text.NumberFormat; import java.util.List; +import okhttp3.OkHttpClient; + public class WalletFragment extends Fragment - implements TransactionInfoAdapter.OnInteractionListener, - AsyncExchangeRate.Listener { + implements TransactionInfoAdapter.OnInteractionListener { public static final String TAG = "WalletFragment"; private TransactionInfoAdapter adapter; private NumberFormat formatter = NumberFormat.getInstance(); @@ -141,48 +147,6 @@ public class WalletFragment extends Fragment return view; } - String balanceCurrency = "XMR"; - double balanceRate = 1.0; - - void refreshBalance() { - if (sCurrency.getSelectedItemPosition() == 0) { // XMR - double amountXmr = Double.parseDouble(Wallet.getDisplayAmount(unlockedBalance)); // assume this cannot fail! - tvBalance.setText(Helper.getFormattedAmount(amountXmr, true)); - } else { // not XMR - String currency = (String) sCurrency.getSelectedItem(); - if (!currency.equals(balanceCurrency) || (balanceRate <= 0)) { - showExchanging(); - new AsyncExchangeRate(this).execute("XMR", currency); - } else { - exchange("XMR", balanceCurrency, balanceRate); - } - } - } - - boolean isExchanging = false; - - void showExchanging() { - isExchanging = true; - tvBalance.setVisibility(View.GONE); - flExchange.setVisibility(View.VISIBLE); - } - - void hideExchanging() { - isExchanging = false; - tvBalance.setVisibility(View.VISIBLE); - flExchange.setVisibility(View.GONE); - } - - // Callbacks from AsyncExchangeRate - - // callback from AsyncExchangeRate when it can't get exchange rate - public void exchangeFailed() { - sCurrency.setSelection(0, true); // default to XMR - double amountXmr = Double.parseDouble(Wallet.getDisplayAmount(unlockedBalance)); // assume this cannot fail! - tvBalance.setText(Helper.getFormattedAmount(amountXmr, true)); - hideExchanging(); - } - void updateBalance() { if (isExchanging) return; // wait for exchange to finish - it will fire this itself then. // at this point selection is XMR in case of error @@ -197,24 +161,90 @@ public class WalletFragment extends Fragment tvBalance.setText(displayB); } - // callback from AsyncExchangeRate when we have a rate - public void exchange(String currencyA, String currencyB, double rate) { + String balanceCurrency = "XMR"; + double balanceRate = 1.0; + + private final ExchangeApi exchangeApi = new ExchangeApiImpl(Helper.getOkHttpClient()); + + void refreshBalance() { + if (sCurrency.getSelectedItemPosition() == 0) { // XMR + double amountXmr = Double.parseDouble(Wallet.getDisplayAmount(unlockedBalance)); // assume this cannot fail! + tvBalance.setText(Helper.getFormattedAmount(amountXmr, true)); + } else { // not XMR + String currency = (String) sCurrency.getSelectedItem(); + if (!currency.equals(balanceCurrency) || (balanceRate <= 0)) { + showExchanging(); + exchangeApi.queryExchangeRate("XMR", currency, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAdded()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchange(exchangeRate); + } + }); + } + + @Override + public void onError(final Exception e) { + Log.e(TAG, e.getLocalizedMessage()); + if (isAdded()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchangeFailed(); + } + }); + } + }); + } else { + updateBalance(); + } + } + } + + boolean isExchanging = false; + + void showExchanging() { + isExchanging = true; + tvBalance.setVisibility(View.GONE); + flExchange.setVisibility(View.VISIBLE); + sCurrency.setEnabled(false); + } + + void hideExchanging() { + isExchanging = false; + tvBalance.setVisibility(View.VISIBLE); + flExchange.setVisibility(View.GONE); + sCurrency.setEnabled(true); + } + + public void exchangeFailed() { + sCurrency.setSelection(0, true); // default to XMR + double amountXmr = Double.parseDouble(Wallet.getDisplayAmount(unlockedBalance)); // assume this cannot fail! + tvBalance.setText(Helper.getFormattedAmount(amountXmr, true)); hideExchanging(); - if (!"XMR".equals(currencyA)) { + } + + public void exchange(final ExchangeRate exchangeRate) { + hideExchanging(); + if (!"XMR".equals(exchangeRate.getBaseCurrency())) { Log.e(TAG, "Not XMR"); sCurrency.setSelection(0, true); balanceCurrency = "XMR"; balanceRate = 1.0; } else { - int spinnerPosition = ((ArrayAdapter) sCurrency.getAdapter()).getPosition(currencyB); + int spinnerPosition = ((ArrayAdapter) sCurrency.getAdapter()).getPosition(exchangeRate.getQuoteCurrency()); if (spinnerPosition < 0) { // requested currency not in list - Log.e(TAG, "Requested currency not in list " + currencyB); + Log.e(TAG, "Requested currency not in list " + exchangeRate.getQuoteCurrency()); sCurrency.setSelection(0, true); } else { sCurrency.setSelection(spinnerPosition, true); } - balanceCurrency = currencyB; - balanceRate = rate; + balanceCurrency = exchangeRate.getQuoteCurrency(); + balanceRate = exchangeRate.getRate(); } updateBalance(); } diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/AsyncExchangeRate.java b/app/src/main/java/com/m2049r/xmrwallet/layout/AsyncExchangeRate.java deleted file mode 100644 index 9d56364..0000000 --- a/app/src/main/java/com/m2049r/xmrwallet/layout/AsyncExchangeRate.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (c) 2017 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. - */ - -package com.m2049r.xmrwallet.layout; - -import android.os.AsyncTask; -import android.util.Log; - -import com.m2049r.xmrwallet.util.Helper; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.lang.ref.WeakReference; - -public class AsyncExchangeRate extends AsyncTask { - static final String TAG = "AsyncExchangeRate"; - static final long TIME_REFRESH_INTERVAL = 60000; // refresh exchange rate max every minute - - public interface Listener { - void exchangeFailed(); - - // callback from AsyncExchangeRate when we have a rate - void exchange(String currencyA, String currencyB, double rate); - } - - static long RateTime = 0; - static double Rate = 0; - static String Fiat = null; - - private final WeakReference exchangeViewRef; - - public AsyncExchangeRate(Listener exchangeView) { - super(); - exchangeViewRef = new WeakReference<>(exchangeView); - } - - @Override - protected void onPreExecute() { - super.onPreExecute(); - } - - boolean inverse = false; - String currencyA = null; - String currencyB = null; - - @Override - protected Boolean doInBackground(String... params) { - if (params.length != 2) return false; - Log.d(TAG, "Getting " + params[0]); - currencyA = params[0]; - currencyB = params[1]; - - String fiat = null; - if (currencyA.equals("XMR")) { - fiat = currencyB; - inverse = false; - } - if (currencyB.equals("XMR")) { - fiat = currencyA; - inverse = true; - } - - if (currencyA.equals(currencyB)) { - Fiat = null; - Rate = 1; - RateTime = System.currentTimeMillis(); - return true; - } - - if (fiat == null) { - Fiat = null; - Rate = 0; - RateTime = 0; - return false; - } - - if (!fiat.equals(Fiat)) { // new currency - reset all - Fiat = fiat; - Rate = 0; - RateTime = 0; - } - - if (System.currentTimeMillis() > RateTime + TIME_REFRESH_INTERVAL) { - Log.d(TAG, "Fetching " + Fiat); - String closePrice = getExchangeRate(Fiat); - if (closePrice != null) { - try { - Rate = Double.parseDouble(closePrice); - RateTime = System.currentTimeMillis(); - return true; - } catch (NumberFormatException ex) { - Rate = 0; - Log.e(TAG, ex.getLocalizedMessage()); - return false; - } - } else { - Rate = 0; - Log.e(TAG, "exchange url failed"); - return false; - } - } - return true; // no change but still valid - } - - @Override - protected void onPostExecute(Boolean result) { - super.onPostExecute(result); - Listener exchangeView = exchangeViewRef.get(); - if (result) { - Log.d(TAG, "yay! = " + Rate); - if (exchangeView != null) { - exchangeView.exchange(currencyA, currencyB, inverse ? (1 / Rate) : Rate); - } - } else { - Log.d(TAG, "nay!"); - if (exchangeView != null) { - exchangeView.exchangeFailed(); - } - } - } - - // "https://api.kraken.com/0/public/Ticker?pair=XMREUR" - String getExchangeRate(String fiat) { - String jsonResponse = - Helper.getUrl("https://api.kraken.com/0/public/Ticker?pair=XMR" + fiat); - if (jsonResponse == null) return null; - try { - JSONObject response = new JSONObject(jsonResponse); - JSONArray errors = response.getJSONArray("error"); - Log.e(TAG, "errors=" + errors.toString()); - if (errors.length() == 0) { - JSONObject result = response.getJSONObject("result"); - JSONObject pair = result.getJSONObject("XXMRZ" + fiat); - JSONArray close = pair.getJSONArray("c"); - String closePrice = close.getString(0); - Log.d(TAG, "closePrice=" + closePrice); - return closePrice; - } - } catch (JSONException ex) { - Log.e(TAG, ex.getLocalizedMessage()); - } - return null; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/m2049r/xmrwallet/layout/ExchangeView.java b/app/src/main/java/com/m2049r/xmrwallet/layout/ExchangeView.java index d85f3ed..a270c0d 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/layout/ExchangeView.java +++ b/app/src/main/java/com/m2049r/xmrwallet/layout/ExchangeView.java @@ -19,6 +19,8 @@ package com.m2049r.xmrwallet.layout; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import android.support.design.widget.TextInputLayout; import android.text.Editable; import android.text.TextWatcher; @@ -37,11 +39,17 @@ 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.service.exchange.kraken.ExchangeApiImpl; import com.m2049r.xmrwallet.util.Helper; import java.util.Locale; -public class ExchangeView extends LinearLayout implements AsyncExchangeRate.Listener { +import okhttp3.OkHttpClient; + +public class ExchangeView extends LinearLayout { static final String TAG = "ExchangeView"; public boolean focus() { @@ -277,11 +285,36 @@ public class ExchangeView extends LinearLayout implements AsyncExchangeRate.List startExchange(); } + private final ExchangeApi exchangeApi = new ExchangeApiImpl(Helper.getOkHttpClient()); + void startExchange() { showProgress(); String currencyA = (String) sCurrencyA.getSelectedItem(); String currencyB = (String) sCurrencyB.getSelectedItem(); - new AsyncExchangeRate(this).execute(currencyA, currencyB); + exchangeApi.queryExchangeRate(currencyA, currencyB, + new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + if (isAttachedToWindow()) + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchange(exchangeRate); + } + }); + } + + @Override + public void onError(final Exception e) { + Log.e(TAG, e.getLocalizedMessage()); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + exchangeFailed(); + } + }); + } + }); } public void exchange(double rate) { @@ -349,7 +382,6 @@ public class ExchangeView extends LinearLayout implements AsyncExchangeRate.List } } - // callback from AsyncExchangeRate when it failed getting a rate public void exchangeFailed() { hideProgress(); exchange(0); @@ -358,19 +390,19 @@ public class ExchangeView extends LinearLayout implements AsyncExchangeRate.List } } - // callback from AsyncExchangeRate when we have a rate - public void exchange(String currencyA, String currencyB, double rate) { + 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 (!currencyA.equals(enteredCurrencyA) || !currencyB.equals(enteredCurrencyB)) { + if (!exchangeRate.getBaseCurrency().equals(enteredCurrencyA) + || !exchangeRate.getQuoteCurrency().equals(enteredCurrencyB)) { // something's wrong Log.e(TAG, "Currencies don't match!"); return; } if (prepareExchange()) { - exchange(rate); + exchange(exchangeRate.getRate()); } } diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java new file mode 100644 index 0000000..9601ba6 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeApi.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * Copyright (c) 2017 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. + */ + +package com.m2049r.xmrwallet.service.exchange.api; + + +import android.support.annotation.NonNull; + + +public interface ExchangeApi { + + /** + * Queries the exchnage rate + * + * @param baseCurrency base currency + * @param quoteCurrency quote currency + * @param callback the callback with the exchange rate + */ + void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback); + +} + diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java new file mode 100644 index 0000000..c5b939c --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeCallback.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * Copyright (c) 2017 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. + */ + +package com.m2049r.xmrwallet.service.exchange.api; + +public interface ExchangeCallback { + + void onSuccess(ExchangeRate exchangeRate); + + void onError(Exception ex); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java new file mode 100644 index 0000000..682c6d1 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeException.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2017 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. + */ + +package com.m2049r.xmrwallet.service.exchange.api; + +public class ExchangeException extends Exception { + private int code; + private final String errorMsg; + + public String getErrorMsg() { + return errorMsg; + } + + public ExchangeException(final int code) { + super(); + this.code = code; + this.errorMsg = null; + } + + public ExchangeException(final String errorMsg) { + super(); + this.code = 0; + this.errorMsg = errorMsg; + } + + public ExchangeException(final int code, final String errorMsg) { + super(); + this.code = code; + this.errorMsg = errorMsg; + } + + public int getCode() { + return code; + } +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java new file mode 100644 index 0000000..3c0fadf --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/api/ExchangeRate.java @@ -0,0 +1,29 @@ +/* + * 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.api; + +public interface ExchangeRate { + + String getServiceName(); + + String getBaseCurrency(); + + String getQuoteCurrency(); + + double getRate(); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiCall.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiCall.java new file mode 100644 index 0000000..3330508 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiCall.java @@ -0,0 +1,24 @@ +/* + * 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.kraken; + +import android.support.annotation.NonNull; + +interface ExchangeApiCall { + + void call(@NonNull final String fiat, @NonNull final NetworkCallback callback); +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java new file mode 100644 index 0000000..848dc4b --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeApiImpl.java @@ -0,0 +1,110 @@ +/* + * 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.kraken; + +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 org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; + +import okhttp3.Call; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class ExchangeApiImpl implements ExchangeApi, ExchangeApiCall { + + @NonNull + private final OkHttpClient okHttpClient; + + private final HttpUrl baseUrl; + + //so we can inject the mockserver url + @VisibleForTesting + 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.kraken.com/0/public/Ticker")); + } + + @Override + public void queryExchangeRate(@NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + ExchangeRateImpl.call(this, baseCurrency, quoteCurrency, callback); + } + + @Override + public void call(@NonNull final String fiat, @NonNull final NetworkCallback callback) { + + final HttpUrl url = baseUrl.newBuilder() + .addQueryParameter("pair", "XMR" + fiat) + .build(); + + final Request httpRequest = createHttpRequest(url); + + 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 { + final JSONObject json = new JSONObject(response.body().string()); + 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("result"); + callback.onSuccess(jsonResult); + } + } catch (JSONException 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(); + } +} + + + diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java new file mode 100644 index 0000000..35263be --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateImpl.java @@ -0,0 +1,142 @@ +/* + * 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.kraken; + +import android.support.annotation.NonNull; + +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.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.NoSuchElementException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class ExchangeRateImpl implements ExchangeRate { + + private final String baseCurrency; + private final String quoteCurrency; + private final double rate; + + @Override + public String getServiceName() { + return "kraken.com"; + } + + @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; + } + + ExchangeRateImpl(final JSONObject jsonObject) throws JSONException, ExchangeException { + try { + final String key = jsonObject.keys().next(); // we expect only one + Pattern pattern = Pattern.compile("^X(.*?)Z(.*?)$"); + Matcher matcher = pattern.matcher(key); + if (matcher.find()) { + this.baseCurrency = matcher.group(1); + this.quoteCurrency = 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 { + this.rate = Double.parseDouble(closePrice); + } catch (NumberFormatException ex) { + throw new ExchangeException(ex.getLocalizedMessage()); + } + } else { + throw new ExchangeException("no close price returned!"); + } + } catch (NoSuchElementException ex) { + throw new ExchangeException(ex.getLocalizedMessage()); + } + } + + public static void call(@NonNull final ExchangeApiCall api, + @NonNull final String baseCurrency, @NonNull final String quoteCurrency, + @NonNull final ExchangeCallback callback) { + + if (baseCurrency.equals(quoteCurrency)) { + callback.onSuccess(new ExchangeRateImpl(baseCurrency, quoteCurrency, 1.0)); + return; + } + + boolean inverse = false; + String fiat = null; + + if (baseCurrency.equals("XMR")) { + fiat = quoteCurrency; + inverse = false; + } + + if (quoteCurrency.equals("XMR")) { + fiat = baseCurrency; + inverse = true; + } + + if (fiat == null) { + callback.onError(new IllegalArgumentException("no fiat specified")); + return; + } + + api.call(fiat, new NetworkCallback() { + @Override + public void onSuccess(JSONObject jsonObject) { + try { + final ExchangeRate exchangeRate = new ExchangeRateImpl(jsonObject); + callback.onSuccess(exchangeRate); + } catch (JSONException ex) { + callback.onError(new ExchangeException(ex.getLocalizedMessage())); + } catch (ExchangeException ex) { + callback.onError(ex); + } + } + + @Override + public void onError(Exception ex) { + callback.onError(ex); + } + }); + } + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/NetworkCallback.java b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/NetworkCallback.java new file mode 100644 index 0000000..8df2e30 --- /dev/null +++ b/app/src/main/java/com/m2049r/xmrwallet/service/exchange/kraken/NetworkCallback.java @@ -0,0 +1,27 @@ +/* + * 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.kraken; + +import org.json.JSONObject; + +interface NetworkCallback { + + void onSuccess(JSONObject jsonObject); + + void onError(Exception ex); + +} diff --git a/app/src/main/java/com/m2049r/xmrwallet/util/AsyncExchangeRate.java b/app/src/main/java/com/m2049r/xmrwallet/util/AsyncExchangeRate.java deleted file mode 100644 index 3b9a3b5..0000000 --- a/app/src/main/java/com/m2049r/xmrwallet/util/AsyncExchangeRate.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) 2017 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. - */ - -package com.m2049r.xmrwallet.util; - -import android.os.AsyncTask; -import android.util.Log; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -public class AsyncExchangeRate extends AsyncTask { - private static final String TAG = "AsyncGetExchangeRate"; - - private static final long TIME_REFRESH_INTERVAL = 60000; // refresh exchange rate max every minute - - private static long RateTime = 0; - private static double Rate = 0; - private static String Fiat = null; - - public interface Listener { - void exchange(String currencyA, String currencyB, double rate); - } - - private Listener listener; - - public AsyncExchangeRate(Listener listener) { - super(); - this.listener = listener; - } - - - @Override - protected void onPreExecute() { - super.onPreExecute(); - } - - private boolean inverse = false; - private String currencyA = null; - private String currencyB = null; - - @Override - protected Boolean doInBackground(String... params) { - if (params.length != 2) return false; - Log.d(TAG, "Getting " + params[0]); - currencyA = params[0]; - currencyB = params[1]; - - String fiat = null; - if (currencyA.equals("XMR")) { - fiat = currencyB; - inverse = false; - } - if (currencyB.equals("XMR")) { - fiat = currencyA; - inverse = true; - } - - if (currencyA.equals(currencyB)) { - Fiat = null; - Rate = 1; - RateTime = System.currentTimeMillis(); - return true; - } - - if (fiat == null) { - Fiat = null; - Rate = 0; - RateTime = 0; - return false; - } - - if (!fiat.equals(Fiat)) { // new currency - reset all - Fiat = fiat; - Rate = 0; - RateTime = 0; - } - - if (System.currentTimeMillis() > RateTime + TIME_REFRESH_INTERVAL) { - Log.d(TAG, "Fetching " + Fiat); - String closePrice = getExchangeRate(Fiat); - if (closePrice != null) { - try { - Rate = Double.parseDouble(closePrice); - RateTime = System.currentTimeMillis(); - return true; - } catch (NumberFormatException ex) { - Rate = 0; - Log.e(TAG, ex.getLocalizedMessage()); - return false; - } - } else { - Rate = 0; - Log.e(TAG, "exchange url failed"); - return false; - } - } - return true; // no change but still valid - } - - @Override - protected void onPostExecute(Boolean result) { - super.onPostExecute(result); - if (result) { - Log.d(TAG, "yay! = " + Rate); - if (listener != null) { - listener.exchange(currencyA, currencyB, inverse ? (1 / Rate) : Rate); - } - } else { - Log.d(TAG, "nay!"); - } - } - - private String getExchangeRate(String fiat) { - String jsonResponse = - Helper.getUrl("https://api.kraken.com/0/public/Ticker?pair=XMR" + fiat); - if (jsonResponse == null) return null; - try { - JSONObject response = new JSONObject(jsonResponse); - JSONArray errors = response.getJSONArray("error"); - Log.e(TAG, "errors=" + errors.toString()); - if (errors.length() == 0) { - JSONObject result = response.getJSONObject("result"); - JSONObject pair = result.getJSONObject("XXMRZ" + fiat); - JSONArray close = pair.getJSONArray("c"); - String closePrice = close.getString(0); - Log.d(TAG, "closePrice=" + closePrice); - return closePrice; - } - } catch (JSONException ex) { - Log.e(TAG, ex.getLocalizedMessage()); - } - return null; - } - - // "https://api.kraken.com/0/public/Ticker?pair=XMREUR" -} \ No newline at end of file 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 4c16650..2399788 100644 --- a/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java +++ b/app/src/main/java/com/m2049r/xmrwallet/util/Helper.java @@ -48,6 +48,8 @@ import java.util.Locale; import javax.net.ssl.HttpsURLConnection; +import okhttp3.OkHttpClient; + public class Helper { static private final String TAG = "Helper"; static private final String WALLET_DIR = "monerujo"; @@ -240,4 +242,17 @@ public class Helper { ClipData clip = ClipData.newPlainText(label, text); clipboardManager.setPrimaryClip(clip); } + + static private OkHttpClient OkHttpClientSingleton; + + static public final OkHttpClient getOkHttpClient() { + if (OkHttpClientSingleton == null) { + synchronized (Helper.class) { + if (OkHttpClientSingleton == null) { + OkHttpClientSingleton = new OkHttpClient(); + } + } + } + return OkHttpClientSingleton; + } } diff --git a/app/src/main/res/layout/fragment_wallet.xml b/app/src/main/res/layout/fragment_wallet.xml index 247c3a1..e78dab6 100644 --- a/app/src/main/res/layout/fragment_wallet.xml +++ b/app/src/main/res/layout/fragment_wallet.xml @@ -57,7 +57,7 @@ android:layout_marginStart="8dp" android:entries="@array/currency" android:gravity="center" - android:paddingBottom="0dp" + android:paddingBottom="2dp" android:paddingEnd="4dp" android:paddingStart="4dp" android:paddingTop="0dp" diff --git a/app/src/test/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateTest.java b/app/src/test/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateTest.java new file mode 100644 index 0000000..dd56285 --- /dev/null +++ b/app/src/test/java/com/m2049r/xmrwallet/service/exchange/kraken/ExchangeRateTest.java @@ -0,0 +1,186 @@ +/* + * 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.kraken; + +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("XMR", "USD", mockExchangeCallback); + + RecordedRequest request = mockWebServer.takeRequest(); + assertEquals("GET", request.getMethod()); + } + + @Test + public void queryExchangeRate_shouldHavePairInUrl() + throws InterruptedException, TimeoutException { + + exchangeApi.queryExchangeRate("XMR", "USD", mockExchangeCallback); + + RecordedRequest request = mockWebServer.takeRequest(); + assertEquals("/?pair=XMRUSD", request.getPath()); + } + + @Test + public void queryExchangeRate_wasSuccessfulShouldRespondWithRate() + throws InterruptedException, JSONException, TimeoutException { + final String base = "XMR"; + final String quote = "USD"; + final double rate = 100; + MockResponse jsonMockResponse = new MockResponse().setBody( + createMockExchangeRateResponse(base, quote, rate)); + mockWebServer.enqueue(jsonMockResponse); + + exchangeApi.queryExchangeRate(base, quote, new ExchangeCallback() { + @Override + public void onSuccess(final ExchangeRate exchangeRate) { + waiter.assertEquals(exchangeRate.getBaseCurrency(), base); + waiter.assertEquals(exchangeRate.getQuoteCurrency(), quote); + waiter.assertEquals(exchangeRate.getRate(), rate); + 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("XMR", "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 { + mockWebServer.enqueue(new MockResponse(). + setResponseCode(200). + setBody("{\"error\":[\"EQuery:Unknown asset pair\"]}")); + exchangeApi.queryExchangeRate("XMR", "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() == 200); + waiter.assertEquals(ex.getErrorMsg(), "EQuery:Unknown asset pair"); + waiter.resume(); + } + + }); + waiter.await(); + } + + private String createMockExchangeRateResponse(final String base, final String quote, final double rate) { + return "{\n" + + " \"error\":[],\n" + + " \"result\":{\n" + + " \"X" + base + "Z" + quote + "\":{\n" + + " \"a\":[\"" + rate + "\",\"322\",\"322.000\"],\n" + + " \"b\":[\"" + rate + "\",\"76\",\"76.000\"],\n" + + " \"c\":[\"" + rate + "\",\"2.90000000\"],\n" + + " \"v\":[\"4559.03962053\",\"5231.33235586\"],\n" + + " \"p\":[\"" + rate + "\",\"" + rate + "\"],\n" + + " \"t\":[801,1014],\n" + + " \"l\":[\"" + (rate * 0.8) + "\",\"" + rate + "\"],\n" + + " \"h\":[\"" + (rate * 1.2) + "\",\"" + rate + "\"],\n" + + " \"o\":\"" + rate + "\"\n" + + " }\n" + + " }\n" + + "}"; + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 69f9fc4..43f9a7e 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { repositories { jcenter() + google() } dependencies { classpath 'com.android.tools.build:gradle:3.0.0' @@ -24,3 +25,9 @@ allprojects { task clean(type: Delete) { delete rootProject.buildDir } + +ext { + okHttpVersion = '3.9.0' + junitVersion = '4.12' + mockitoVersion = '1.10.19' +}