diff --git a/App.js b/App.js index 1fb5e3197..15eeae204 100644 --- a/App.js +++ b/App.js @@ -38,6 +38,7 @@ import Biometric from './class/biometrics'; import WidgetCommunication from './blue_modules/WidgetCommunication'; import changeNavigationBarColor from 'react-native-navigation-bar-color'; const A = require('./blue_modules/analytics'); +const currency = require('./blue_modules/currency'); const eventEmitter = new NativeEventEmitter(NativeModules.EventEmitter); @@ -262,43 +263,43 @@ const App = () => { }; const handleAppStateChange = async nextAppState => { - if (wallets.length > 0) { - if ((appState.current.match(/background/) && nextAppState) === 'active' || nextAppState === undefined) { - setTimeout(() => A(A.ENUM.APP_UNSUSPENDED), 2000); - const processed = await processPushNotifications(); - if (processed) return; - const clipboard = await BlueClipboard.getClipboardContent(); - const isAddressFromStoredWallet = wallets.some(wallet => { - if (wallet.chain === Chain.ONCHAIN) { - // checking address validity is faster than unwrapping hierarchy only to compare it to garbage - return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); - } else { - return wallet.isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); - } - }); - const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); - const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); - const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); - const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); - if ( - !isAddressFromStoredWallet && - clipboardContent.current !== clipboard && - (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) - ) { - if (isBitcoinAddress) { - setClipboardContentType(ClipboardContentType.BITCOIN); - } else if (isLightningInvoice || isLNURL) { - setClipboardContentType(ClipboardContentType.LIGHTNING); - } else if (isBothBitcoinAndLightning) { - setClipboardContentType(ClipboardContentType.BITCOIN); - } - setIsClipboardContentModalVisible(true); + if (wallets.length === 0) return; + if ((appState.current.match(/background/) && nextAppState === 'active') || nextAppState === undefined) { + setTimeout(() => A(A.ENUM.APP_UNSUSPENDED), 2000); + currency.updateExchangeRate(); + const processed = await processPushNotifications(); + if (processed) return; + const clipboard = await BlueClipboard.getClipboardContent(); + const isAddressFromStoredWallet = wallets.some(wallet => { + if (wallet.chain === Chain.ONCHAIN) { + // checking address validity is faster than unwrapping hierarchy only to compare it to garbage + return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); + } else { + return wallet.isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); } - clipboardContent.current = clipboard; - } - if (nextAppState) { - appState.current = nextAppState; + }); + const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); + const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); + const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); + const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); + if ( + !isAddressFromStoredWallet && + clipboardContent.current !== clipboard && + (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) + ) { + if (isBitcoinAddress) { + setClipboardContentType(ClipboardContentType.BITCOIN); + } else if (isLightningInvoice || isLNURL) { + setClipboardContentType(ClipboardContentType.LIGHTNING); + } else if (isBothBitcoinAndLightning) { + setClipboardContentType(ClipboardContentType.BITCOIN); + } + setIsClipboardContentModalVisible(true); } + clipboardContent.current = clipboard; + } + if (nextAppState) { + appState.current = nextAppState; } }; diff --git a/BlueApp.js b/BlueApp.js index 4b0a1fcfd..065680388 100644 --- a/BlueApp.js +++ b/BlueApp.js @@ -69,6 +69,6 @@ const startAndDecrypt = async retry => { }; BlueApp.startAndDecrypt = startAndDecrypt; -currency.startUpdater(); +currency.init(); module.exports = BlueApp; diff --git a/blue_modules/currency.js b/blue_modules/currency.js index 1d0713ae5..cfb7ac73e 100644 --- a/blue_modules/currency.js +++ b/blue_modules/currency.js @@ -5,15 +5,14 @@ import BigNumber from 'bignumber.js'; import { FiatUnit, getFiatRate } from '../models/fiatUnit'; import WidgetCommunication from './WidgetCommunication'; -const PREFERRED_CURRENCY = 'preferredCurrency'; -const EXCHANGE_RATES = 'currency'; +const PREFERRED_CURRENCY_STORAGE_KEY = 'preferredCurrency'; +const EXCHANGE_RATES_STORAGE_KEY = 'currency'; let preferredFiatCurrency = FiatUnit.USD; -const exchangeRates = {}; +let exchangeRates = {}; +let lastTimeUpdateExchangeRateWasCalled = 0; -const STRUCT = { - LAST_UPDATED: 'LAST_UPDATED', -}; +const LAST_UPDATED = 'LAST_UPDATED'; /** * Saves to storage preferred currency, whole object @@ -23,7 +22,7 @@ const STRUCT = { * @returns {Promise} */ async function setPrefferedCurrency(item) { - await AsyncStorage.setItem(PREFERRED_CURRENCY, JSON.stringify(item)); + await AsyncStorage.setItem(PREFERRED_CURRENCY_STORAGE_KEY, JSON.stringify(item)); await DefaultPreference.setName('group.io.bluewallet.bluewallet'); await DefaultPreference.set('preferredCurrency', item.endPointKey); await DefaultPreference.set('preferredCurrencyLocale', item.locale.replace('-', '_')); @@ -31,21 +30,25 @@ async function setPrefferedCurrency(item) { } async function getPreferredCurrency() { - const preferredCurrency = await JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY)); + const preferredCurrency = await JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); await DefaultPreference.setName('group.io.bluewallet.bluewallet'); await DefaultPreference.set('preferredCurrency', preferredCurrency.endPointKey); await DefaultPreference.set('preferredCurrencyLocale', preferredCurrency.locale.replace('-', '_')); return preferredCurrency; } -async function updateExchangeRate() { - if (+new Date() - exchangeRates[STRUCT.LAST_UPDATED] <= 30 * 60 * 1000) { - // not updating too often - return; - } - +async function _restoreSavedExchangeRatesFromStorage() { try { - preferredFiatCurrency = JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY)); + exchangeRates = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); + if (!exchangeRates) exchangeRates = {}; + } catch (_) { + exchangeRates = {}; + } +} + +async function _restoreSavedPreferredFiatCurrencyFromStorage() { + try { + preferredFiatCurrency = JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); if (preferredFiatCurrency === null) { throw Error('No Preferred Fiat selected'); } @@ -57,38 +60,66 @@ async function updateExchangeRate() { preferredFiatCurrency = FiatUnit.USD; } } +} + +/** + * actual function to reach api and get fresh currency exchange rate. checks LAST_UPDATED time and skips entirely + * if called too soon (30min); saves exchange rate (with LAST_UPDATED info) to storage. + * should be called when app thinks its a good time to refresh exchange rate + * + * @return {Promise} + */ +async function updateExchangeRate() { + if (+new Date() - lastTimeUpdateExchangeRateWasCalled <= 10 * 1000) { + // simple debounce so theres no race conditions + return; + } + lastTimeUpdateExchangeRateWasCalled = +new Date(); + + if (+new Date() - exchangeRates[LAST_UPDATED] <= 30 * 60 * 1000) { + // not updating too often + return; + } + console.log('updating exchange rate...'); let rate; try { rate = await getFiatRate(preferredFiatCurrency.endPointKey); } catch (Err) { console.warn(Err.message); - const lastSavedExchangeRate = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES)); - exchangeRates['BTC_' + preferredFiatCurrency.endPointKey] = lastSavedExchangeRate['BTC_' + preferredFiatCurrency.endPointKey] * 1; return; } - exchangeRates[STRUCT.LAST_UPDATED] = +new Date(); + exchangeRates[LAST_UPDATED] = +new Date(); exchangeRates['BTC_' + preferredFiatCurrency.endPointKey] = rate; - await AsyncStorage.setItem(EXCHANGE_RATES, JSON.stringify(exchangeRates)); - await AsyncStorage.setItem(PREFERRED_CURRENCY, JSON.stringify(preferredFiatCurrency)); - await setPrefferedCurrency(preferredFiatCurrency); + await AsyncStorage.setItem(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(exchangeRates)); } -let interval = false; -async function startUpdater() { - if (interval) { - clearInterval(interval); - exchangeRates[STRUCT.LAST_UPDATED] = 0; +/** + * this function reads storage and restores current preferred fiat currency & last saved exchange rate, then calls + * updateExchangeRate() to update rates. + * should be called when the app starts and when user changes preferred fiat (with TRUE argument so underlying + * `updateExchangeRate()` would actually update rates via api). + * + * @param clearLastUpdatedTime {boolean} set to TRUE for the underlying + * + * @return {Promise} + */ +async function init(clearLastUpdatedTime = false) { + await _restoreSavedExchangeRatesFromStorage(); + await _restoreSavedPreferredFiatCurrencyFromStorage(); + + if (clearLastUpdatedTime) { + exchangeRates[LAST_UPDATED] = 0; + lastTimeUpdateExchangeRateWasCalled = 0; } - interval = setInterval(() => updateExchangeRate(), 2 * 60 * 100); return updateExchangeRate(); } function satoshiToLocalCurrency(satoshi) { if (!exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]) { - startUpdater(); + updateExchangeRate(); return '...'; } @@ -169,8 +200,7 @@ function _setExchangeRate(pair, rate) { } module.exports.updateExchangeRate = updateExchangeRate; -module.exports.startUpdater = startUpdater; -module.exports.STRUCT = STRUCT; +module.exports.init = init; module.exports.satoshiToLocalCurrency = satoshiToLocalCurrency; module.exports.fiatToBTC = fiatToBTC; module.exports.satoshiToBTC = satoshiToBTC; @@ -181,5 +211,6 @@ module.exports.btcToSatoshi = btcToSatoshi; module.exports.getCurrencySymbol = getCurrencySymbol; module.exports._setPreferredFiatCurrency = _setPreferredFiatCurrency; // export it to mock data in tests module.exports._setExchangeRate = _setExchangeRate; // export it to mock data in tests -module.exports.PREFERRED_CURRENCY = PREFERRED_CURRENCY; -module.exports.EXCHANGE_RATES = EXCHANGE_RATES; +module.exports.PREFERRED_CURRENCY = PREFERRED_CURRENCY_STORAGE_KEY; +module.exports.EXCHANGE_RATES = EXCHANGE_RATES_STORAGE_KEY; +module.exports.LAST_UPDATED = LAST_UPDATED; diff --git a/screen/settings/currency.js b/screen/settings/currency.js index e03c617e0..0473584af 100644 --- a/screen/settings/currency.js +++ b/screen/settings/currency.js @@ -63,7 +63,7 @@ const Currency = () => { setIsSavingNewPreferredCurrency(true); setSelectedCurrency(item); await currency.setPrefferedCurrency(item); - await currency.startUpdater(); + await currency.init(true); setIsSavingNewPreferredCurrency(false); setPreferredFiatCurrency(); }} diff --git a/tests/integration/Currency.test.js b/tests/integration/Currency.test.js index b10d2adf6..b9f3cdba9 100644 --- a/tests/integration/Currency.test.js +++ b/tests/integration/Currency.test.js @@ -3,28 +3,26 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { FiatUnit } from '../../models/fiatUnit'; -jest.useFakeTimers(); - describe('currency', () => { it('fetches exchange rate and saves to AsyncStorage', async () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; const currency = require('../../blue_modules/currency'); - await currency.startUpdater(); + await currency.init(); let cur = await AsyncStorage.getItem(currency.EXCHANGE_RATES); cur = JSON.parse(cur); - assert.ok(Number.isInteger(cur[currency.STRUCT.LAST_UPDATED])); - assert.ok(cur[currency.STRUCT.LAST_UPDATED] > 0); + assert.ok(Number.isInteger(cur[currency.LAST_UPDATED])); + assert.ok(cur[currency.LAST_UPDATED] > 0); assert.ok(cur.BTC_USD > 0); // now, setting other currency as default await AsyncStorage.setItem(currency.PREFERRED_CURRENCY, JSON.stringify(FiatUnit.JPY)); - await currency.startUpdater(); + await currency.init(true); cur = JSON.parse(await AsyncStorage.getItem(currency.EXCHANGE_RATES)); assert.ok(cur.BTC_JPY > 0); // now setting with a proper setter await currency.setPrefferedCurrency(FiatUnit.EUR); - await currency.startUpdater(); + await currency.init(true); const preferred = await currency.getPreferredCurrency(); assert.strictEqual(preferred.endPointKey, 'EUR'); cur = JSON.parse(await AsyncStorage.getItem(currency.EXCHANGE_RATES)); @@ -32,20 +30,20 @@ describe('currency', () => { // test Yadio rate source await currency.setPrefferedCurrency(FiatUnit.ARS); - await currency.startUpdater(); + await currency.init(true); cur = JSON.parse(await AsyncStorage.getItem(currency.EXCHANGE_RATES)); assert.ok(cur.BTC_ARS > 0); // test BitcoinduLiban rate source // disabled, because it throws "Service Temporarily Unavailable" on circleci // await currency.setPrefferedCurrency(FiatUnit.LBP); - // await currency.startUpdater(); + // await currency.init(true); // cur = JSON.parse(await AsyncStorage.getItem(currency.EXCHANGE_RATES)); // assert.ok(cur.BTC_LBP > 0); // test Exir rate source await currency.setPrefferedCurrency(FiatUnit.IRR); - await currency.startUpdater(); + await currency.init(true); cur = JSON.parse(await AsyncStorage.getItem(currency.EXCHANGE_RATES)); assert.ok(cur.BTC_IRR > 0); });