From 4ef1b81984840d359b973a6501350dc0d6f1b4dc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 8 May 2024 06:21:15 -0700 Subject: [PATCH] feat: warn user of stale exchange rates (#1284) * feat: support detection of stale rates * feat: add new Toast based on Compose --- common/build.gradle | 1 + .../wallet/common/data/ExchangeRatesConfig.kt | 25 ++++ .../wallet/common/data/entity/ExchangeRate.kt | 27 +++- .../common/services/ExchangeRatesProvider.kt | 1 + .../common/services/RateRetrievalState.kt | 26 ++++ .../ui/components/ComposeHostFrameLayout.kt | 30 +++++ .../wallet/common/ui/components/MyTheme.kt | 48 +++++++ .../dash/wallet/common/ui/components/Toast.kt | 122 ++++++++++++++++++ .../src/main/res/drawable/ic_toast_copy.xml | 14 ++ .../src/main/res/drawable/ic_toast_error.xml | 10 ++ .../src/main/res/drawable/ic_toast_info.xml | 10 ++ .../res/drawable/ic_toast_info_warning.xml | 10 ++ .../main/res/drawable/ic_toast_no_wifi.xml | 9 ++ wallet/build.gradle | 7 +- wallet/res/values/strings-extra.xml | 5 + .../wallet/database/dao/ExchangeRatesDao.kt | 3 + .../wallet/rates/DashRetailClient.java | 2 +- .../wallet/rates/DashRetailRate.java | 19 ++- .../wallet/rates/ExchangeRatesRepository.kt | 75 ++++++++++- .../wallet/ui/main/MainViewModel.kt | 23 ++++ .../wallet/ui/main/WalletActivity.java | 28 +++- .../wallet/ui/main/WalletActivityExt.kt | 115 ++++++++++++++++- 22 files changed, 592 insertions(+), 18 deletions(-) create mode 100644 common/src/main/java/org/dash/wallet/common/data/ExchangeRatesConfig.kt create mode 100644 common/src/main/java/org/dash/wallet/common/services/RateRetrievalState.kt create mode 100644 common/src/main/java/org/dash/wallet/common/ui/components/ComposeHostFrameLayout.kt create mode 100644 common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt create mode 100644 common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt create mode 100644 common/src/main/res/drawable/ic_toast_copy.xml create mode 100644 common/src/main/res/drawable/ic_toast_error.xml create mode 100644 common/src/main/res/drawable/ic_toast_info.xml create mode 100644 common/src/main/res/drawable/ic_toast_info_warning.xml create mode 100644 common/src/main/res/drawable/ic_toast_no_wifi.xml diff --git a/common/build.gradle b/common/build.gradle index aea172fb5f..b1d67f689e 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") + debugImplementation 'androidx.compose.ui:ui-tooling:1.0.0' implementation "com.github.bumptech.glide:glide:$glideVersion" ksp "com.github.bumptech.glide:compiler:$glideVersion" diff --git a/common/src/main/java/org/dash/wallet/common/data/ExchangeRatesConfig.kt b/common/src/main/java/org/dash/wallet/common/data/ExchangeRatesConfig.kt new file mode 100644 index 0000000000..5dd70b7235 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/data/ExchangeRatesConfig.kt @@ -0,0 +1,25 @@ +package org.dash.wallet.common.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import org.dash.wallet.common.WalletDataProvider +import javax.inject.Inject + +class ExchangeRatesConfig + @Inject + constructor( + context: Context, + walletDataProvider: WalletDataProvider + ) : BaseConfig( + context, + PREFERENCES_NAME, + walletDataProvider + ) { + companion object { + private const val PREFERENCES_NAME = "exchange_rates_config" + val EXCHANGE_RATES_RETRIEVAL_TIME = longPreferencesKey("exchange_rates_retrieval_time") + val EXCHANGE_RATES_RETRIEVAL_FAILURE = booleanPreferencesKey("exchange_rates_retrieval_error") + val EXCHANGE_RATES_PREVIOUS_RETRIEVAL_TIME = longPreferencesKey("exchange_rates_previous_retrieval_time") + } + } diff --git a/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt b/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt index 047b8c852d..1b928336e0 100644 --- a/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt +++ b/common/src/main/java/org/dash/wallet/common/data/entity/ExchangeRate.kt @@ -34,6 +34,8 @@ open class ExchangeRate : Parcelable { @PrimaryKey var currencyCode: String var rate: String? = null + @Ignore + var retrievalTime: Long = -1L @Ignore private var currencyName: String? = null @@ -41,9 +43,16 @@ open class ExchangeRate : Parcelable { @Ignore private var currency: Currency? = null + constructor(currencyCode: String, rate: String?, retrievalTime: Long) { + this.currencyCode = currencyCode + this.rate = rate + this.retrievalTime = retrievalTime + } + constructor(currencyCode: String, rate: String?) { this.currencyCode = currencyCode this.rate = rate + this.retrievalTime = -1 } protected constructor(input: Parcel) { @@ -79,13 +88,16 @@ open class ExchangeRate : Parcelable { // If the currency is not a valid ISO 4217 code, then set the // currency name to be equal to the currency code // exchanges often have "invalid" currency codes like USDT and CNH - currencyName = if (currencyCode.length == 3) { - try { - getCurrency().displayName - } catch (x: IllegalArgumentException) { + currencyName = + if (currencyCode.length == 3) { + try { + getCurrency().displayName + } catch (x: IllegalArgumentException) { + currencyCode + } + } else { currencyCode } - } else currencyCode if (currencyCode.equals(currencyName!!, ignoreCase = true)) { currencyName = CurrencyInfo.getOtherCurrencyName(currencyCode, context) } @@ -97,7 +109,10 @@ open class ExchangeRate : Parcelable { return 0 } - override fun writeToParcel(dest: Parcel, flags: Int) { + override fun writeToParcel( + dest: Parcel, + flags: Int + ) { dest.writeString(currencyCode) dest.writeString(rate) dest.writeString(currencyName) diff --git a/common/src/main/java/org/dash/wallet/common/services/ExchangeRatesProvider.kt b/common/src/main/java/org/dash/wallet/common/services/ExchangeRatesProvider.kt index d6626cd109..f8b2845850 100644 --- a/common/src/main/java/org/dash/wallet/common/services/ExchangeRatesProvider.kt +++ b/common/src/main/java/org/dash/wallet/common/services/ExchangeRatesProvider.kt @@ -25,4 +25,5 @@ interface ExchangeRatesProvider { fun observeExchangeRate(currencyCode: String): Flow suspend fun getExchangeRate(currencyCode: String): ExchangeRate? suspend fun cleanupObsoleteCurrencies() + fun observeStaleRates(currencyCode: String): Flow } diff --git a/common/src/main/java/org/dash/wallet/common/services/RateRetrievalState.kt b/common/src/main/java/org/dash/wallet/common/services/RateRetrievalState.kt new file mode 100644 index 0000000000..db62957382 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/services/RateRetrievalState.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.services + +data class RateRetrievalState( + val lastAttemptFailed: Boolean = false, + val staleRates: Boolean, + val volatile: Boolean +) { + val isStale = lastAttemptFailed || staleRates || volatile +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/ComposeHostFrameLayout.kt b/common/src/main/java/org/dash/wallet/common/ui/components/ComposeHostFrameLayout.kt new file mode 100644 index 0000000000..de58294431 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/ComposeHostFrameLayout.kt @@ -0,0 +1,30 @@ +package org.dash.wallet.common.ui.components + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView + +class ComposeHostFrameLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 + ) : FrameLayout(context, attrs, defStyleAttr) { + private var composeView: ComposeView? = null + + fun setContent(content: @Composable () -> Unit) { + if (composeView == null) { + composeView = ComposeView(context) + addView(composeView) + } + composeView?.setContent(content) + } + + override fun removeAllViews() { + composeView = null + super.removeAllViews() + } + } diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt b/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt new file mode 100644 index 0000000000..4bd661bdc9 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/MyTheme.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.dash.wallet.common.ui.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import org.dash.wallet.common.R + +object MyTheme { + val ToastBackground = Color(0xff191c1f).copy(alpha = 0.9f) + + val Body2Regular = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontFamily = FontFamily.Default, // FontFamily(Font(R.font.inter)) // crashes, + fontWeight = FontWeight(400), + color = Color.White + ) + + val OverlineSemibold = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontFamily = FontFamily.Default, // FontFamily(Font(R.font.inter)) // crashes, + fontWeight = FontWeight(600), + color = Color.White, + textAlign = TextAlign.Center + ) +} \ No newline at end of file diff --git a/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt b/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt new file mode 100644 index 0000000000..4d2e4cc561 --- /dev/null +++ b/common/src/main/java/org/dash/wallet/common/ui/components/Toast.kt @@ -0,0 +1,122 @@ +package org.dash.wallet.common.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.dash.wallet.common.R + +enum class ToastDuration { + SHORT, + LONG, + INDEFINITE +} + +enum class ToastImageResource(@DrawableRes val resourceId: Int) { + Information(R.drawable.ic_toast_info), + Warning(R.drawable.ic_toast_info_warning), + Copy(R.drawable.ic_toast_copy), + Error(R.drawable.ic_toast_error), + NoInternet(R.drawable.ic_toast_no_wifi) +} + +@Composable +fun Toast( + text: String, + actionText: String, + modifier: Modifier = Modifier, + imageResource: Int? = null, + onActionClick: () -> Unit +) { + Box( + modifier = + modifier.fillMaxWidth() + .padding(horizontal = 5.dp, vertical = 5.dp) + .background( + color = MyTheme.ToastBackground, + shape = RoundedCornerShape(size = 10.dp) + ) + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .wrapContentSize(Alignment.BottomCenter) + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = + Modifier + .weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + if (imageResource != null) { + Image( + painter = painterResource(id = imageResource), + contentDescription = null, + modifier = + Modifier + .size(15.dp) + ) + } + Text( + text = text, + style = MyTheme.Body2Regular, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp, end = 8.dp) + ) + } + TextButton( + onClick = onActionClick, + modifier = + Modifier + .padding(start = 0.dp) + .wrapContentSize() + .padding(horizontal = 0.dp, vertical = 0.dp), + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = actionText, + style = MyTheme.OverlineSemibold, + textAlign = TextAlign.End + ) + } + } + } +} + +@Preview +@Composable +private fun ToastPreview() { + Box(Modifier.width(400.dp).height(100.dp).background(Color.White)) { + Toast( + text = "The exchange rates are out of date, please do something about it right away", + actionText = "OK", + Modifier, + R.drawable.ic_image_placeholder + ) { + } + } +} diff --git a/common/src/main/res/drawable/ic_toast_copy.xml b/common/src/main/res/drawable/ic_toast_copy.xml new file mode 100644 index 0000000000..67abccd337 --- /dev/null +++ b/common/src/main/res/drawable/ic_toast_copy.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_toast_error.xml b/common/src/main/res/drawable/ic_toast_error.xml new file mode 100644 index 0000000000..dd3a2affe3 --- /dev/null +++ b/common/src/main/res/drawable/ic_toast_error.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_toast_info.xml b/common/src/main/res/drawable/ic_toast_info.xml new file mode 100644 index 0000000000..d09b4faa46 --- /dev/null +++ b/common/src/main/res/drawable/ic_toast_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_toast_info_warning.xml b/common/src/main/res/drawable/ic_toast_info_warning.xml new file mode 100644 index 0000000000..be5f37366e --- /dev/null +++ b/common/src/main/res/drawable/ic_toast_info_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_toast_no_wifi.xml b/common/src/main/res/drawable/ic_toast_no_wifi.xml new file mode 100644 index 0000000000..6fae325799 --- /dev/null +++ b/common/src/main/res/drawable/ic_toast_no_wifi.xml @@ -0,0 +1,9 @@ + + + diff --git a/wallet/build.gradle b/wallet/build.gradle index 666a43f5c9..b82429b7c7 100644 --- a/wallet/build.gradle +++ b/wallet/build.gradle @@ -83,8 +83,13 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:$constrainLayoutVersion" implementation "com.github.bumptech.glide:glide:$glideVersion" ksp "com.github.bumptech.glide:compiler:$glideVersion" - implementation 'com.github.MikeOrtiz:TouchImageView:3.0.3' + implementation 'com.github.MikeOrtiz:TouchImageView:3.0.6' implementation "io.coil-kt:coil:$coilVersion" + implementation(platform("androidx.compose:compose-bom:2023.03.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + debugImplementation 'androidx.compose.ui:ui-tooling:1.0.0' // Navigation implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion" diff --git a/wallet/res/values/strings-extra.xml b/wallet/res/values/strings-extra.xml index ebb8eca799..d577cd50cc 100644 --- a/wallet/res/values/strings-extra.xml +++ b/wallet/res/values/strings-extra.xml @@ -416,4 +416,9 @@ Not used Revoked Private / Public Keys (base64) + + + Prices weren\'t retrieved. Fiat values may be incorrect. + Prices are at least 30 minutes old. Fiat values may be incorrect. + Prices have fluctuated more than 50% since the last update. \ No newline at end of file diff --git a/wallet/src/de/schildbach/wallet/database/dao/ExchangeRatesDao.kt b/wallet/src/de/schildbach/wallet/database/dao/ExchangeRatesDao.kt index 07ca7e4a3a..95c7bf580e 100644 --- a/wallet/src/de/schildbach/wallet/database/dao/ExchangeRatesDao.kt +++ b/wallet/src/de/schildbach/wallet/database/dao/ExchangeRatesDao.kt @@ -46,4 +46,7 @@ interface ExchangeRatesDao { @Query("DELETE FROM exchange_rates WHERE currencyCode IN (:currencyCodes)") suspend fun delete(currencyCodes: Collection) + + @Query("SELECT * FROM exchange_rates ORDER BY currencyCode") + suspend fun getAll(): List } diff --git a/wallet/src/de/schildbach/wallet/rates/DashRetailClient.java b/wallet/src/de/schildbach/wallet/rates/DashRetailClient.java index fb9d5f9c5f..0fd17f220c 100644 --- a/wallet/src/de/schildbach/wallet/rates/DashRetailClient.java +++ b/wallet/src/de/schildbach/wallet/rates/DashRetailClient.java @@ -50,7 +50,7 @@ public List getRates() throws Exception { List exchangeRates = new ArrayList<>(); for (DashRetailRate rate : rates) { if (DASH_CURRENCY_SYMBOL.equals(rate.getBaseCurrency())) { - exchangeRates.add(new ExchangeRate(rate.getQuoteCurrency(), rate.getPrice().toPlainString())); + exchangeRates.add(new ExchangeRate(rate.getQuoteCurrency(), rate.getPrice().toPlainString(), rate.getRetreivalDate())); } } diff --git a/wallet/src/de/schildbach/wallet/rates/DashRetailRate.java b/wallet/src/de/schildbach/wallet/rates/DashRetailRate.java index 8ef5d9f509..c49ae1467c 100644 --- a/wallet/src/de/schildbach/wallet/rates/DashRetailRate.java +++ b/wallet/src/de/schildbach/wallet/rates/DashRetailRate.java @@ -1,17 +1,22 @@ package de.schildbach.wallet.rates; import java.math.BigDecimal; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.TimeZone; public class DashRetailRate { private final String baseCurrency; private final String quoteCurrency; private final BigDecimal price; + private final String retrieved; - public DashRetailRate(String baseCurrency, String quoteCurrency, BigDecimal price) { + public DashRetailRate(String baseCurrency, String quoteCurrency, BigDecimal price, String retrieved) { this.baseCurrency = baseCurrency; this.quoteCurrency = quoteCurrency; this.price = price; + this.retrieved = retrieved; } public String getBaseCurrency() { @@ -26,4 +31,16 @@ public BigDecimal getPrice() { return price; } + public long getRetreivalDate() { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); // Set timezone to UTC to match the 'Z' (Zulu time) + + try { + return dateFormat.parse(retrieved).getTime(); + } catch (ParseException e) { + e.printStackTrace(); + return System.currentTimeMillis(); + } + } + } diff --git a/wallet/src/de/schildbach/wallet/rates/ExchangeRatesRepository.kt b/wallet/src/de/schildbach/wallet/rates/ExchangeRatesRepository.kt index 5712ada056..e987e4f5e4 100644 --- a/wallet/src/de/schildbach/wallet/rates/ExchangeRatesRepository.kt +++ b/wallet/src/de/schildbach/wallet/rates/ExchangeRatesRepository.kt @@ -4,12 +4,23 @@ import androidx.lifecycle.MutableLiveData import de.schildbach.wallet.database.dao.ExchangeRatesDao import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import org.dash.wallet.common.data.CurrencyInfo +import org.dash.wallet.common.data.ExchangeRatesConfig +import org.dash.wallet.common.data.ExchangeRatesConfig.Companion.EXCHANGE_RATES_PREVIOUS_RETRIEVAL_TIME +import org.dash.wallet.common.data.ExchangeRatesConfig.Companion.EXCHANGE_RATES_RETRIEVAL_FAILURE +import org.dash.wallet.common.data.ExchangeRatesConfig.Companion.EXCHANGE_RATES_RETRIEVAL_TIME import org.dash.wallet.common.data.entity.ExchangeRate import org.dash.wallet.common.services.ExchangeRatesProvider +import org.dash.wallet.common.services.RateRetrievalState import org.slf4j.LoggerFactory +import java.math.BigDecimal import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -18,11 +29,13 @@ import javax.inject.Inject * @author Samuel Barbosa */ class ExchangeRatesRepository @Inject constructor( - private val exchangeRatesDao: ExchangeRatesDao + private val exchangeRatesDao: ExchangeRatesDao, + private val config: ExchangeRatesConfig ): ExchangeRatesProvider { companion object { private val log = LoggerFactory.getLogger(ExchangeRatesRepository::class.java) private val UPDATE_FREQ_MS = TimeUnit.SECONDS.toMillis(30) + private val STALE_DURATION_MS = TimeUnit.MINUTES.toMillis(30) } private val exchangeRatesClients: Deque = ArrayDeque() @@ -34,6 +47,9 @@ class ExchangeRatesRepository @Inject constructor( var hasError = MutableLiveData() private var isRefreshing = false private val refreshScope = CoroutineScope(Dispatchers.IO) + private val updateTrigger = MutableSharedFlow() + // used to detect 50% change in rates since the last rates (volatile) + private val previousRates = arrayListOf() init { populateExchangeRatesStack() @@ -71,10 +87,16 @@ class ExchangeRatesRepository @Inject constructor( } if (rates.isNotEmpty()) { + previousRates.clear() + previousRates.addAll(exchangeRatesDao.getAll()) exchangeRatesDao.insertAll(rates) lastUpdated = System.currentTimeMillis() populateExchangeRatesStack() hasError.postValue(false) + config.set(EXCHANGE_RATES_RETRIEVAL_FAILURE, false) + val prevRetrievalTime = config.get(EXCHANGE_RATES_RETRIEVAL_TIME) ?: 0 + config.set(EXCHANGE_RATES_PREVIOUS_RETRIEVAL_TIME, prevRetrievalTime) + config.set(EXCHANGE_RATES_RETRIEVAL_TIME, System.currentTimeMillis()) isRefreshing = false log.info("exchange rates updated successfully with {}", exchangeRatesClient) } else if (!exchangeRatesClients.isEmpty()) { @@ -91,12 +113,15 @@ class ExchangeRatesRepository @Inject constructor( } } finally { isLoading.postValue(false) + updateTrigger.emit(System.currentTimeMillis()) + log.info("updateTrigger") } } } private suspend fun handleRefreshError() { isRefreshing = false + config.set(EXCHANGE_RATES_RETRIEVAL_FAILURE, true) if (exchangeRatesDao.count() == 0) { hasError.postValue(true) } @@ -128,4 +153,52 @@ class ExchangeRatesRepository @Inject constructor( } return exchangeRatesDao.observeRate(currencyCode) } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeStaleRates(currencyCode: String): Flow = updateTrigger + .mapLatest { + val currentTime = System.currentTimeMillis() + val lastRetrievalTime = config.get(EXCHANGE_RATES_RETRIEVAL_TIME) ?: System.currentTimeMillis() + + // are rates older than 30 minutes? + val staleRate = if ((currentTime - lastRetrievalTime) > STALE_DURATION_MS) { + true + } else { + val lastRate = exchangeRatesDao.getExchangeRateForCurrency(currencyCode) + lastRate != null && lastRate.retrievalTime != -1L && + (currentTime - lastRate.retrievalTime) > STALE_DURATION_MS + } + val previousRetrievalTime = config.get(EXCHANGE_RATES_PREVIOUS_RETRIEVAL_TIME) ?: 0 + // have rates changed by 50% since the last values? + val volatile = if (lastRetrievalTime - previousRetrievalTime < TimeUnit.DAYS.toMillis(7)) { + val previousRate = previousRates.find { it.currencyCode == currencyCode } + previousRate?.let { prev -> + prev.rate?.let { prevRate -> + val oldRate = prevRate.toBigDecimal() + exchangeRatesDao.getExchangeRateForCurrency(currencyCode)?.let { new -> + new.rate?.let { newRate -> + (newRate.toBigDecimal() - oldRate) / oldRate > BigDecimal(0.50) + } + } + } + } ?: false + } else { + false + } + val retrievalError = config.get(EXCHANGE_RATES_RETRIEVAL_FAILURE) ?: false + RateRetrievalState( + retrievalError, + staleRate, + if (retrievalError || staleRate) false else volatile + ) + } + .catch { + log.error("updateTrigger exception caught:", it) + RateRetrievalState( + false, + false, + false + ) + } + .distinctUntilChanged() } diff --git a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt index 8935006e53..20bc82cd24 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/MainViewModel.kt @@ -44,6 +44,7 @@ import org.dash.wallet.common.data.entity.BlockchainState import org.dash.wallet.common.data.entity.ExchangeRate import org.dash.wallet.common.services.BlockchainStateProvider import org.dash.wallet.common.services.ExchangeRatesProvider +import org.dash.wallet.common.services.RateRetrievalState import org.dash.wallet.common.services.TransactionMetadataProvider import org.dash.wallet.common.services.analytics.AnalyticsConstants import org.dash.wallet.common.services.analytics.AnalyticsService @@ -113,6 +114,19 @@ class MainViewModel @Inject constructor( val exchangeRate: LiveData get() = _exchangeRate + private val _rateStale = MutableStateFlow( + RateRetrievalState( + lastAttemptFailed = false, + staleRates = false, + volatile = false + ) + ) + val rateStale: Flow + get() = _rateStale + val currentStaleRateState + get() = _rateStale.value + var rateStaleDismissed = false + private val _balance = MutableLiveData() val balance: LiveData get() = _balance @@ -181,6 +195,15 @@ class MainViewModel @Inject constructor( } .onEach(_exchangeRate::postValue) .launchIn(viewModelScope) + + walletUIConfig + .observe(WalletUIConfig.SELECTED_CURRENCY) + .filterNotNull() + .flatMapLatest { code -> + exchangeRatesProvider.observeStaleRates(code) + } + .onEach(_rateStale::emit) + .launchIn(viewModelScope) } fun logEvent(event: String) { diff --git a/wallet/src/de/schildbach/wallet/ui/main/WalletActivity.java b/wallet/src/de/schildbach/wallet/ui/main/WalletActivity.java index e9589e8fb5..ed54953548 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/WalletActivity.java +++ b/wallet/src/de/schildbach/wallet/ui/main/WalletActivity.java @@ -27,15 +27,21 @@ import android.os.PowerManager; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.lifecycle.ViewModelProvider; +import com.google.android.material.snackbar.Snackbar; import com.google.common.collect.ImmutableList; import org.bitcoinj.crypto.ChildNumber; import org.bitcoinj.wallet.Wallet; +import org.dash.wallet.common.services.RateRetrievalState; import org.dash.wallet.common.ui.BaseAlertDialogBuilder; +import org.dash.wallet.common.ui.components.ComposeHostFrameLayout; import org.dash.wallet.common.ui.dialogs.AdaptiveDialog; +import org.dash.wallet.common.util.FlowExtKt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,7 +61,10 @@ import de.schildbach.wallet.util.Nfc; import de.schildbach.wallet_test.R; import kotlin.Unit; +import kotlin.coroutines.Continuation; import kotlin.jvm.functions.Function0; +import kotlinx.coroutines.flow.Flow; +import kotlinx.coroutines.flow.FlowCollector; /** * @author Andreas Schildbach @@ -76,7 +85,8 @@ public static Intent createIntent(Context context) { private boolean isRestoringBackup; private BaseAlertDialogBuilder baseAlertDialogBuilder; - private MainViewModel viewModel; + public MainViewModel viewModel; + public ComposeHostFrameLayout composeHostFrameLayout = null; ActivityResultLauncher requestPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), result -> WalletActivityExt.INSTANCE.requestDisableBatteryOptimisation(WalletActivity.this)); @@ -112,6 +122,16 @@ protected void onCreate(final Bundle savedInstanceState) { currencies.component2() ) ); + + FlowExtKt.observe(viewModel.getRateStale(), this, new FlowCollector() { + @Nullable + @Override + public Object emit(RateRetrievalState state, @NonNull Continuation continuation) { + log.info("updateTrigger => rateStale: {}", state); + WalletActivityExt.INSTANCE.showStaleRatesToast(WalletActivity.this); + return Unit.INSTANCE; + } + }); } @Override @@ -364,5 +384,11 @@ public void onLockScreenDeactivated() { if (configuration.getShowNotificationsExplainer()) { WalletActivityExt.INSTANCE.explainPushNotifications(this); } + WalletActivityExt.INSTANCE.showStaleRatesToast(this); + } + + @Override + public void onLockScreenActivated() { + WalletActivityExt.INSTANCE.showStaleRatesToast(this); } } diff --git a/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt b/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt index 096d4ff26d..ddec7dd70d 100644 --- a/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt +++ b/wallet/src/de/schildbach/wallet/ui/main/WalletActivityExt.kt @@ -28,6 +28,21 @@ import android.os.PowerManager import android.os.storage.StorageManager import android.provider.Settings import android.view.MenuItem +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.view.isVisible @@ -38,8 +53,13 @@ import androidx.navigation.ui.NavigationUI.setupWithNavController import com.google.android.material.bottomnavigation.BottomNavigationView import de.schildbach.wallet.WalletBalanceWidgetProvider import de.schildbach.wallet_test.R +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.dash.wallet.common.services.analytics.AnalyticsConstants +import org.dash.wallet.common.ui.components.ComposeHostFrameLayout +import org.dash.wallet.common.ui.components.Toast +import org.dash.wallet.common.ui.components.ToastDuration +import org.dash.wallet.common.ui.components.ToastImageResource import org.dash.wallet.common.ui.dialogs.AdaptiveDialog import org.dash.wallet.common.ui.dialogs.AdaptiveDialog.Companion.create import org.dash.wallet.common.util.openCustomTab @@ -179,13 +199,14 @@ object WalletActivityExt { } private fun WalletActivity.showLowStorageAlertDialog() { - val storageManagerIntent = Intent( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - StorageManager.ACTION_MANAGE_STORAGE - } else { - Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS - } - ) + val storageManagerIntent = + Intent( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + StorageManager.ACTION_MANAGE_STORAGE + } else { + Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS + } + ) val hasStorageManager = packageManager.resolveActivity(storageManagerIntent, 0) != null AdaptiveDialog.create( @@ -257,4 +278,84 @@ object WalletActivityExt { } } } + + /** + * show a single toast that is created with Compose + */ + private fun WalletActivity.showToast( + visible: Boolean, + imageResource: ToastImageResource? = null, + messageText: String? = null, + duration: ToastDuration = ToastDuration.INDEFINITE, + actionText: String? = null, + onActionClick: (() -> Unit)? = null, + ) { + if (composeHostFrameLayout == null) { + composeHostFrameLayout = ComposeHostFrameLayout(this) + composeHostFrameLayout.layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT // Convert dp to pixels for height + ).apply { + gravity = android.view.Gravity.BOTTOM // Align to bottom of FrameLayout + } + val rootView = findViewById(android.R.id.content) + rootView.addView(composeHostFrameLayout) + } + composeHostFrameLayout.setContent { + if (visible && messageText != null) { + var showToast by remember { mutableStateOf(true) } + if (showToast) { + if (duration != ToastDuration.INDEFINITE) { + LaunchedEffect(key1 = true) { + delay(if (duration == ToastDuration.SHORT) 3000L else 10000L) + showToast = false + } + } + MaterialTheme { + val density = LocalDensity.current + val bottom = WindowInsets.systemBars.getBottom(density) + val top = WindowInsets.systemBars.getTop(density) + Box( + modifier = + Modifier + .padding( + top = top.dp, + bottom = bottom.dp + ) + ) { + // rh + Toast( + text = messageText, + actionText = actionText ?: getString(R.string.button_ok), + imageResource = imageResource?.resourceId + ) { + showToast = false + onActionClick?.invoke() + } + } + } + } + } + } + } + + fun WalletActivity.showStaleRatesToast() { + val staleRateState = viewModel.currentStaleRateState + val message = when { + staleRateState.volatile -> getString(R.string.stale_exchange_rates_volatile) + staleRateState.staleRates -> getString(R.string.stale_exchange_rates_stale) + staleRateState.lastAttemptFailed -> getString(R.string.stale_exchange_rates_error) + else -> null + } + showToast( + !lockScreenDisplayed && staleRateState.isStale && !viewModel.rateStaleDismissed, + imageResource = ToastImageResource.Warning, + messageText = message, + actionText = getString(R.string.button_ok) + ) { + // never show a stale rate message until app is restarted + viewModel.rateStaleDismissed = true + } + } }