diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 9966a479f5..4f7bd9e0d5 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -176,6 +176,7 @@ data class AppCacheData( val bolt11PaymentHash: String = "", val bip21: String = "", val balance: BalanceState? = null, + val usdtDisplayBalance: CachedUsdtBalance? = null, val backupStatuses: Map = mapOf(), val deletedActivities: List = listOf(), val detachedActivityContacts: Set = emptySet(), @@ -207,3 +208,13 @@ data class BlocktankRefundAddress( val address: String, val index: Long, ) + +@Serializable +data class CachedUsdtBalance( + val address: String, + val network: String, + val balance: ULong, +) { + fun balanceFor(address: String, network: String): ULong? = + balance.takeIf { this.address == address && this.network == network } +} diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index c1e115de79..06cefa01a7 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -111,21 +111,21 @@ class CurrencyRepo @Inject constructor( combine( settingsStore.data.distinctUntilChanged(), cacheStore.data.distinctUntilChanged() - ) { settings, cachedData -> + ) { settings, cachedData -> settings to cachedData }.collect { (settings, cachedData) -> val selectedRate = cachedData.cachedRates.firstOrNull { rate -> rate.quote == settings.selectedCurrency } - _currencyState.value.copy( - rates = cachedData.cachedRates.toImmutableList(), - selectedCurrency = settings.selectedCurrency, - displayUnit = settings.displayUnit, - primaryDisplay = settings.primaryDisplay, - currencySymbol = selectedRate?.currencySymbol ?: "$", - error = null, - hasStaleData = false - ) - }.collect { newState -> - _currencyState.update { newState } + _currencyState.update { + it.copy( + rates = cachedData.cachedRates.toImmutableList(), + selectedCurrency = settings.selectedCurrency, + displayUnit = settings.displayUnit, + primaryDisplay = settings.primaryDisplay, + currencySymbol = selectedRate?.currencySymbol ?: "$", + error = null, + hasStaleData = false, + ) + } } } } @@ -137,6 +137,7 @@ class CurrencyRepo @Inject constructor( private suspend fun refresh() { if (isRefreshing) return isRefreshing = true + _currencyState.update { it.copy(isRefreshing = true) } try { runSuspendCatching { val fetchedRates = currencyService.fetchLatestRates() @@ -160,6 +161,7 @@ class CurrencyRepo @Inject constructor( } } finally { isRefreshing = false + _currencyState.update { it.copy(isRefreshing = false) } } } @@ -273,6 +275,7 @@ data class CurrencyState( val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, val lastSuccessfulRefresh: Long? = null, + val isRefreshing: Boolean = false, ) val CurrencyState.paykitRate: PaykitExchangeRate? diff --git a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt index 779784dc67..751487a428 100644 --- a/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/UsdtRepo.kt @@ -21,18 +21,23 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.data.CacheStore +import to.bitkit.data.CachedUsdtBalance import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PaykitUsdt import to.bitkit.services.UsdtService import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import java.security.MessageDigest import java.text.DecimalFormatSymbols import java.util.Locale import javax.inject.Inject @@ -44,9 +49,16 @@ import kotlin.time.Instant @Singleton class UsdtRepo @Inject constructor( private val service: UsdtService, + private val cacheStore: CacheStore, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val clock: Clock = Clock.System, ) { + companion object { + private const val TAG = "UsdtRepo" + } + + private val displayCacheNetwork = MessageDigest.getInstance("SHA-256") + .digest("${Env.network}:${Env.usdtRpcUrl}:${Env.usdtBundlerUrl}".encodeToByteArray()).contentToString() private val mutex = Mutex() @Volatile var isSendPresented = false @@ -65,35 +77,55 @@ class UsdtRepo @Inject constructor( if (clock.now().toEpochMilliseconds() < refreshAfter) { return@withLock if (force) Result.failure(UsdtException.RateLimited()) else Result.success(Unit) } - runSuspendCatching { - val wallet = service.wallet() - val address = wallet.receiveAddress() - if (_state.value.address != address) _state.update { UsdtWalletState(address = address) } - val previous = _state.value.transfers.takeIf { _state.value.historyComplete } - val saved = wallet.history().toImmutableList() - _state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) } - val settlement = runSuspendCatching { - val balance = wallet.balance() - _state.update { it.copy(balance = balance) } - val transfers = wallet.refreshTransfers().toImmutableList() - _state.update { it.copy(transfers = transfers) } - } - if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow() - if (history && !isSendPresented) { - val complete = wallet.syncHistory() - val current = wallet.history().toImmutableList() - _state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) } - announceNewReceipts(previous, current) - } - settlement.getOrThrow() - }.also { - if (it.exceptionOrNull() is UsdtException.RateLimited) { - rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds + _state.update { it.copy(refreshing = true, balanceIsStale = true) } + try { + runSuspendCatching { + val wallet = service.wallet() + val address = wallet.receiveAddress() + if (_state.value.address != address) { + _state.update { UsdtWalletState(address = address, refreshing = true) } + } + if (_state.value.balance == null) { + runSuspendCatching { cacheStore.data.first().usdtDisplayBalance } + .onSuccess { cached -> + _state.update { + it.copy(cachedBalance = cached?.balanceFor(address, displayCacheNetwork)) + } + }.onFailure { Logger.warn("Failed to load display balance", it, context = TAG) } + } + val previous = _state.value.transfers.takeIf { _state.value.historyComplete } + val saved = wallet.history().toImmutableList() + _state.update { it.copy(receiveUri = wallet.receiveUri(), transfers = saved) } + val settlement = runSuspendCatching { + val balance = wallet.balance() + _state.update { it.copy(balance = balance, balanceIsStale = false) } + runSuspendCatching { + cacheStore.update { + it.copy(usdtDisplayBalance = CachedUsdtBalance(address, displayCacheNetwork, balance)) + } + }.onFailure { Logger.warn("Failed to cache display balance", it, context = TAG) } + val transfers = wallet.refreshTransfers().toImmutableList() + _state.update { it.copy(transfers = transfers) } + } + if (settlement.exceptionOrNull() is UsdtException.RateLimited) settlement.getOrThrow() + if (history && !isSendPresented) { + val complete = wallet.syncHistory() + val current = wallet.history().toImmutableList() + _state.update { it.copy(historyComplete = it.historyComplete || complete, transfers = current) } + announceNewReceipts(previous, current) + } + settlement.getOrThrow() + }.also { + if (it.exceptionOrNull() is UsdtException.RateLimited) { + rateLimitedUntil = clock.now().toEpochMilliseconds() + 60.seconds.inWholeMilliseconds + } + nextRefresh = maxOf( + rateLimitedUntil, + clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds + ) } - nextRefresh = maxOf( - rateLimitedUntil, - clock.now().toEpochMilliseconds() + 10.seconds.inWholeMilliseconds - ) + } finally { + _state.update { it.copy(refreshing = false) } } } } @@ -155,9 +187,13 @@ class UsdtRepo @Inject constructor( runSuspendCatching { val wallet = service.wallet(requiresNetwork = false) wallet.restoreBackup(snapshot) + clearDisplayBalance() _state.update { it.copy( address = wallet.receiveAddress(), + balance = null, + cachedBalance = null, + balanceIsStale = true, transfers = wallet.history().toImmutableList(), historyComplete = false, pendingReceipts = persistentListOf(), @@ -194,6 +230,7 @@ class UsdtRepo @Inject constructor( suspend fun wipe(clearCredentials: suspend () -> Unit = {}) = withContext(ioDispatcher) { mutex.withLock { + clearDisplayBalance() clearCredentials() runSuspendCatching { service.wipe() }.also { _state.update { UsdtWalletState() } @@ -203,6 +240,11 @@ class UsdtRepo @Inject constructor( } } + private suspend fun clearDisplayBalance() { + runSuspendCatching { cacheStore.update { it.copy(usdtDisplayBalance = null) } } + .onFailure { Logger.warn("Failed to clear display balance", it, context = TAG) } + } + suspend fun quote( recipient: String, amount: String, @@ -264,6 +306,9 @@ data class UsdtWalletState( val address: String = "", val receiveUri: String = "", val balance: ULong? = null, + val cachedBalance: ULong? = null, + val balanceIsStale: Boolean = true, + val refreshing: Boolean = false, val transfers: ImmutableList = persistentListOf(), val historyComplete: Boolean = false, val pendingReceipts: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5c75d59a64..3f68440496 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -100,7 +100,6 @@ import androidx.navigation.NavController import androidx.navigation.NavHostController import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.UsdtTransferStatus -import com.synonym.bitkitcore.usdtFormatAmount import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource @@ -126,7 +125,6 @@ import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem import to.bitkit.models.HwWallet import to.bitkit.models.MoneyType -import to.bitkit.models.PaykitExchangeRate import to.bitkit.models.Suggestion import to.bitkit.models.TransportType import to.bitkit.models.WidgetSize @@ -145,6 +143,7 @@ import to.bitkit.ui.appViewModel import to.bitkit.ui.components.ActivityBanner import to.bitkit.ui.components.AppStatus import to.bitkit.ui.components.BalanceHeaderView +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.EmptyStateView import to.bitkit.ui.components.FillHeight @@ -164,7 +163,6 @@ import to.bitkit.ui.components.Title import to.bitkit.ui.components.TopBarSpacer import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.WalletBalanceView -import to.bitkit.ui.components.usdtDisplaySats import to.bitkit.ui.navToActivityDetail import to.bitkit.ui.navigateTo import to.bitkit.ui.navigateToAllActivity @@ -623,9 +621,6 @@ private fun UsdtHomeRefreshEffect() { UsdtRefreshEffect(viewModel, pending = wallet.transfers.any { it.status == UsdtTransferStatus.PENDING }) } -private fun usdtHeadlineSats(balance: ULong?, visible: Boolean, rate: PaykitExchangeRate?): Long? = - if (visible && balance != 0uL) balance?.let { usdtDisplaySats(usdtFormatAmount(it), rate) } else 0L - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun WalletPage( @@ -649,7 +644,8 @@ private fun WalletPage( val slotCapacity = if (isSmallScreen) SMALL_SCREEN_SLOT_CAPACITY else LARGE_SCREEN_SLOT_CAPACITY val showUsdt = appViewModel?.isUsdtVisible?.collectAsStateWithLifecycle()?.value == true val rate = LocalCurrencies.current.paykitRate - val usdtSats = usdtHeadlineSats(usdt.balance, showUsdt, rate) + val usdtSats = usdtHeadlineSats(usdt.balance ?: usdt.cachedBalance, showUsdt, rate) + val balanceUpdating = showUsdt && usdtBalanceUpdating(usdt, LocalCurrencies.current) val headlineSats = BigInteger( balances.totalWithHardwareSats.toString() ).add(BigInteger.valueOf(usdtSats ?: 0L)).min(BigInteger.valueOf(Long.MAX_VALUE)).toLong() @@ -692,6 +688,13 @@ private fun WalletPage( .fillMaxWidth() .testTag("TotalBalance") ) + if (balanceUpdating) { + Caption13Up( + text = stringResource(R.string.wallet__balance_updating), + color = Colors.White64, + modifier = Modifier.padding(top = 8.dp).testTag("TotalBalanceUpdating") + ) + } VerticalSpacer(32.dp) BalancesSection( balances = balances, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 57a6b36f7e..afee832b40 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -136,7 +136,7 @@ class HomeViewModel @Inject constructor( }.combine( combine(settingsStore.data, usdtRepo.state) { settings, usdt -> Env.isUsdtEnabled && (settings.isUsdtEnabled || usdt.hasFundsOrPendingTransfers) && - ((usdt.balance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty()) + ((usdt.balance ?: usdt.cachedBalance ?: 0uL) > 0uL || usdt.transfers.isNotEmpty()) } ) { hasActivity, hasUsdtActivity -> hasActivity || hasUsdtActivity diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt new file mode 100644 index 0000000000..715b20dab0 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeWalletBalance.kt @@ -0,0 +1,30 @@ +package to.bitkit.ui.screens.wallets + +import to.bitkit.ext.nowMillis +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset +import to.bitkit.models.PaykitExchangeRate +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.repositories.paykitRate +import to.bitkit.ui.components.usdtDisplaySats + +internal fun usdtHeadlineSats( + balance: ULong?, + visible: Boolean, + rate: PaykitExchangeRate?, + now: Long = nowMillis(), +): Long? { + if (!visible || balance == 0uL) return 0L + if (balance == null || rate == null || rate.timestampMillis > now) return null + // Home can show the last known value; payment conversions still require a fresh rate. + return usdtDisplaySats(PaykitAmount(PaykitAsset.USDT, balance).value, rate, now = rate.timestampMillis) +} + +internal fun usdtBalanceUpdating(usdt: UsdtWalletState, currency: CurrencyState, now: Long = nowMillis()): Boolean = + usdt.balanceIsStale || usdt.refreshing || + ( + usdt.balance != 0uL && ( + currency.isRefreshing || runCatching { currency.paykitRate?.value(now) }.getOrNull() == null + ) + ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index 07f16c65a1..8d733dcdff 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -103,7 +103,7 @@ fun RowScope.UsdtWalletBalance(onClick: () -> Unit, viewModel: UsdtViewModel = h if (settings.hideBalance) { UiConstants.HIDE_BALANCE_SHORT } else { - wallet.balance?.let { "$ " + usdtOverviewAmount(it) } ?: "—" + (wallet.balance ?: wallet.cachedBalance)?.let { "$ " + usdtOverviewAmount(it) } ?: "—" }, style = UsdtDetailStyles.HistoryTitle ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0894e7c3af..347f4831c6 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1732,4 +1732,5 @@ إعدادات الأداة صغير عريض + جارٍ تحديث الرصيد… diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index b051b9e1e7..1909cb5311 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f83790081a..f80d8e1ccc 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1729,4 +1729,5 @@ Configuració del giny Petit Ample + S’està actualitzant el saldo… diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index bb7a644e4b..320928f091 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1730,4 +1730,5 @@ Nastavení widgetu Malý Široký + Aktualizace zůstatku… diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index efc30cca07..1f18e1a92f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1728,4 +1728,5 @@ Widget-Einstellungen Klein Breit + Kontostand wird aktualisiert… diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 3800135d9c..54236b6eff 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1728,4 +1728,5 @@ Ρυθμίσεις widget Μικρό Φαρδύ + Ενημέρωση υπολοίπου… diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index fc09657b40..6b53d84af6 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5072785f9b..44b48ab09b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1729,4 +1729,5 @@ Ajustes del widget Pequeño Ancho + Actualizando saldo… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6948ef3d1e..12ec18223c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1729,4 +1729,5 @@ Paramètres du widget Petit Large + Mise à jour du solde… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1689758d4e..9640894d20 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1729,4 +1729,5 @@ Impostazioni Widget Piccolo Largo + Aggiornamento del saldo… diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2cb52c4df6..c1309d8df6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1728,4 +1728,5 @@ Widgetinstellingen Klein Breed + Saldo bijwerken… diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6b90799070..1f0efff06a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1730,4 +1730,5 @@ Ustawienia widgetu Mały Szeroki + Aktualizowanie salda… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 53396ccd4e..2ba1218bb3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1729,4 +1729,5 @@ Configurações do Widget Pequeno Largo + Atualizando saldo… diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 33a4fed7a7..18f8c590ce 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1729,4 +1729,5 @@ Definições do widget Pequeno Largo + A atualizar o saldo… diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b60527452..f3e9f37990 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1757,4 +1757,5 @@ Bitkit должен увеличить приёмную ёмкость ваше Настройки Виджета Маленький Широкий + Обновление баланса… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6434bbe..b4738bdfd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1740,4 +1740,5 @@ Small Wide Widgets + Updating balance… diff --git a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt index 2401dfcb86..f539d78118 100644 --- a/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt +++ b/app/src/test/java/to/bitkit/data/AppCacheDataTest.kt @@ -24,6 +24,18 @@ class AppCacheDataTest : BaseUnitTest() { "vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql" } + @Test + fun `cached USDT balance round trips exact values and rejects another wallet or network`() { + for (balance in listOf(0uL, 1_234_567uL, ULong.MAX_VALUE)) { + val original = AppCacheData(usdtDisplayBalance = CachedUsdtBalance("wallet-a", "network-a", balance)) + val restored = json.decodeFromString(json.encodeToString(original)).usdtDisplayBalance!! + assertEquals(balance, restored.balanceFor("wallet-a", "network-a")) + assertNull(restored.balanceFor("wallet-b", "network-a")) + assertNull(restored.balanceFor("wallet-a", "network-b")) + } + assertNull(json.decodeFromString("{}").usdtDisplayBalance) + } + @Test fun `AppCacheSerializer reads a legacy receive invoice without a stored payment hash`() = test { val cachedReceive = AppCacheSerializer.readFrom( diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index ce7ec0bfd5..342cce6162 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -2,10 +2,16 @@ package to.bitkit.repositories import app.cash.turbine.test import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -25,6 +31,8 @@ import to.bitkit.services.CurrencyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError import java.math.BigDecimal +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -32,6 +40,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -225,7 +234,9 @@ class CurrencyRepoTest : BaseUnitTest() { sut = createSut() val job = launch { sut.triggerRefresh() } + assertTrue(sut.currencyState.value.isRefreshing) job.cancelAndJoin() + assertFalse(sut.currencyState.value.isRefreshing) assertNull(sut.currencyState.value.error) @@ -234,6 +245,55 @@ class CurrencyRepoTest : BaseUnitTest() { assertNull(sut.currencyState.value.error) verify(currencyService, times(2)).fetchLatestRates() } + + @Test + fun `cached rates should preserve completed refresh state`() = test { + val cacheData = MutableStateFlow(AppCacheData()) + whenever(cacheStore.data).thenReturn(cacheData) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + val refreshedAt = Clock.System.now() + whenever(clock.now()).thenReturn(refreshedAt) + whenever(currencyService.fetchLatestRates()).thenReturn(testRates) + val collectorRead = CompletableDeferred() + val releaseCollector = CountDownLatch(1) + var ratesReads = 0 + val refreshedCache = mock() + whenever(refreshedCache.cachedRates).thenAnswer { + ratesReads++ + if (ratesReads == 2) { + collectorRead.complete(Unit) + assertTrue(releaseCollector.await(5, TimeUnit.SECONDS)) + } + testRates + } + whenever(cacheStore.update(any())).doSuspendableAnswer { + cacheData.update { refreshedCache } + collectorRead.await() + } + sut = CurrencyRepo( + bgDispatcher = Dispatchers.Default, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + clock = clock, + ) + + try { + withContext(Dispatchers.Default) { + withTimeout(5.seconds) { + sut.currencyState.first { it.selectedCurrency == "EUR" } + sut.triggerRefresh() + releaseCollector.countDown() + sut.currencyState.first { it.rates == testRates } + } + } + assertFalse(sut.currencyState.value.isRefreshing) + assertEquals(refreshedAt.toEpochMilliseconds(), sut.currencyState.value.lastSuccessfulRefresh) + } finally { + releaseCollector.countDown() + } + } } private class CurrencyRepoTestError(message: String) : AppError(message) diff --git a/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt b/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt index 3dc840d08a..9802b75b0b 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtEndpointTestWallet.kt @@ -8,8 +8,11 @@ import kotlinx.coroutines.CoroutineDispatcher import org.mockito.Mockito.mockStatic import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.keychain.Keychain import to.bitkit.services.UsdtService +import to.bitkit.test.InMemoryDataStore internal class UsdtEndpointTestWallet(dispatcher: CoroutineDispatcher) : AutoCloseable { companion object { @@ -22,7 +25,7 @@ internal class UsdtEndpointTestWallet(dispatcher: CoroutineDispatcher) : AutoClo var derivationAvailable = true private val keychain = mock() private val native = mockStatic(Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt")) - val repo = UsdtRepo(UsdtService(keychain, mock()), dispatcher) + val repo = UsdtRepo(UsdtService(keychain, mock()), CacheStore(InMemoryDataStore(AppCacheData())), dispatcher) init { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenAnswer { diff --git a/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt index c7ee42e293..0cd5050008 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtPreferencesRepoTest.kt @@ -15,11 +15,14 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.UsdtPreference import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.InMemoryDataStore import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -158,7 +161,7 @@ class UsdtPreferencesRepoTest : BaseUnitTest() { whenever(coreWallet.receiveUri()).thenReturn("wallet-uri") whenever(coreWallet.history()).thenReturn(listOf(settled)) whenever(coreWallet.balance()).thenAnswer { throw AppError("offline") } - val usdt = UsdtRepo(service, testDispatcher) + val usdt = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(usdt.refresh(history = false).isFailure) val preferences = UsdtPreferencesRepo(settingsStore, usdt, testDispatcher) assertEquals(null, usdt.state.value.balance) @@ -179,7 +182,7 @@ class UsdtPreferencesRepoTest : BaseUnitTest() { whenever(coreWallet.balance()).thenReturn(0u) whenever(coreWallet.history()).thenReturn(emptyList()) whenever(coreWallet.refreshTransfers()).thenReturn(emptyList()) - val usdt = UsdtRepo(service, testDispatcher) + val usdt = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) usdt.refresh(history = false).getOrThrow() val pending: UsdtDeposit = mock { on { id } doReturn "pending-deposit" diff --git a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt index 38b530957f..e35802ccc4 100644 --- a/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/UsdtRepoTest.kt @@ -1,5 +1,6 @@ package to.bitkit.repositories +import androidx.datastore.core.DataStore import com.synonym.bitkitcore.UsdtDeposit import com.synonym.bitkitcore.UsdtDestination import com.synonym.bitkitcore.UsdtException @@ -10,20 +11,28 @@ import com.synonym.bitkitcore.UsdtWallet import com.synonym.bitkitcore.usdtParseAmount import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import org.junit.Test import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore import to.bitkit.services.UsdtService import to.bitkit.test.BaseUnitTest +import to.bitkit.test.InMemoryDataStore +import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -39,6 +48,87 @@ class UsdtRepoTest : BaseUnitTest() { on { this.expiresAt }.thenReturn(expiresAt) } + @Test + fun `display cache survives restart and offline refresh but never becomes a spendable balance`() = test { + val cache = CacheStore(InMemoryDataStore(AppCacheData())) + whenever(service.wallet()).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("wallet-a") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_234_567uL) + assertTrue(UsdtRepo(service, cache, testDispatcher).refresh(history = false, force = true).isSuccess) + whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) + val restarted = UsdtRepo(service, cache, testDispatcher) + assertTrue(restarted.refresh(history = false, force = true).isFailure) + assertEquals(1_234_567uL, restarted.state.value.cachedBalance) + assertNull(restarted.state.value.balance) + assertTrue(restarted.state.value.balanceIsStale) + assertFalse(restarted.state.value.refreshing) + doReturn(0uL).whenever(wallet).balance() + assertTrue(restarted.refresh(history = false, force = true).isSuccess) + assertEquals(0uL, restarted.state.value.balance) + assertFalse(restarted.state.value.balanceIsStale) + assertEquals(0uL, cache.data.first().usdtDisplayBalance?.balance) + whenever(wallet.receiveAddress()).thenReturn("wallet-b") + whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) + assertTrue(restarted.refresh(history = false, force = true).isFailure) + assertNull(restarted.state.value.cachedBalance) + assertNull(restarted.state.value.balance) + restarted.wipe().getOrThrow() + assertNull(cache.data.first().usdtDisplayBalance) + } + + @Test + fun `display cache write failure does not prevent credential and wallet cleanup`() = test { + val repo = UsdtRepo(service, failingCache(), testDispatcher) + val pending: UsdtDeposit = mock { + on { id }.thenReturn("pending") + on { status }.thenReturn("confirming") + } + repo.updateDeposits(listOf(pending)) + var credentialsCleared = false + + repo.wipe { credentialsCleared = true }.getOrThrow() + + assertTrue(credentialsCleared) + verify(service).wipe() + assertEquals(UsdtWalletState(), repo.state.value) + val walletError = UsdtException.Storage("wallet cleanup failed") + whenever(service.wipe()).thenAnswer { throw walletError } + assertEquals(walletError, repo.wipe().exceptionOrNull()) + } + + @Test + fun `display cache write failure does not leave old state after wallet restoration`() = test { + whenever(service.wallet()).thenReturn(wallet) + whenever(service.wallet(requiresNetwork = false)).thenReturn(wallet) + whenever(wallet.receiveAddress()).thenReturn("original") + whenever(wallet.receiveUri()).thenReturn("uri") + whenever(wallet.history()).thenReturn(emptyList()) + whenever(wallet.refreshTransfers()).thenReturn(emptyList()) + whenever(wallet.balance()).thenReturn(1_000_000uL) + val repo = UsdtRepo(service, failingCache(), testDispatcher) + repo.refresh(history = false, force = true).getOrThrow() + whenever(wallet.receiveAddress()).thenReturn("restored") + whenever(wallet.history()).thenReturn(listOf(incoming("restored-payment"))) + + repo.restoreBackup("snapshot").getOrThrow() + + verify(wallet).restoreBackup("snapshot") + assertEquals("restored", repo.state.value.address) + assertEquals(listOf(incoming("restored-payment")), repo.state.value.transfers) + assertNull(repo.state.value.balance) + assertNull(repo.state.value.cachedBalance) + assertTrue(repo.state.value.balanceIsStale) + } + + private fun failingCache() = CacheStore(object : DataStore { + override val data = flowOf(AppCacheData()) + override suspend fun updateData(transform: suspend (AppCacheData) -> AppCacheData): AppCacheData = + throw IOException("cache unavailable") + }) + @Test fun `quote rate limits pause manual payment retries and refresh until the cooldown expires`() = test { var now = Instant.fromEpochSeconds(1_800_000_000) @@ -47,7 +137,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveAddress()).thenReturn("sender") whenever(wallet.quoteMaxTransfer("recipient", UsdtDestination.ARBITRUM)) .thenThrow(UsdtException.RateLimited()).thenReturn(quote()) - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) assertTrue( repo.quoteMaximum("recipient", UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtException.RateLimited ) @@ -71,7 +161,7 @@ class UsdtRepoTest : BaseUnitTest() { @Test fun `unused disabled wallet does not refresh but stored recovery and opt in do`() { - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(false, repo.shouldRefresh(enabled = false)) assertTrue(repo.shouldRefresh(enabled = true)) whenever(service.hasStoredWallet).thenReturn(true) @@ -84,7 +174,7 @@ class UsdtRepoTest : BaseUnitTest() { val address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E" whenever(service.wallet()).thenReturn(wallet) whenever(wallet.receiveAddress()).thenReturn(address) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val recipient = " ${address.lowercase()} " assertTrue(repo.quote(recipient, "1", UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) assertTrue(repo.quoteMaximum(recipient, UsdtDestination.ARBITRUM).exceptionOrNull() is UsdtSelfSendError) @@ -102,7 +192,7 @@ class UsdtRepoTest : BaseUnitTest() { @Test fun `known unsettled deposits protect the wallet until their updated status is settled`() = test { - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val pending: UsdtDeposit = mock { on { id }.thenReturn("deposit-one") on { status }.thenReturn("confirming") @@ -131,7 +221,7 @@ class UsdtRepoTest : BaseUnitTest() { finish.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val deposits = UsdtDepositRepo(repo) val request = launch { deposits.depositNetworks().getOrThrow() } started.await() @@ -156,7 +246,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveUri()).thenReturn("uri") whenever(wallet.history()).thenReturn(listOf(transfer)) whenever(wallet.balance()).thenThrow(UsdtException.NetworkUnavailable()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).isFailure) assertEquals(listOf(transfer), repo.state.value.transfers) assertEquals("address", repo.state.value.address) @@ -171,7 +261,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenThrow(UsdtException.NetworkUnavailable()) whenever(wallet.syncHistory()).thenReturn(true) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).isFailure) assertEquals(listOf(incoming("received")), repo.state.value.transfers) assertTrue(repo.state.value.historyComplete) @@ -186,7 +276,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.history()).thenReturn(emptyList()) whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(true, force = true).exceptionOrNull() is UsdtException.RateLimited) verify(wallet, never()).syncHistory() } @@ -197,9 +287,9 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.receiveAddress()).thenReturn("address") whenever(wallet.receiveUri()).thenReturn("uri") whenever(wallet.history()).thenReturn(emptyList()) - whenever(wallet.balance()).thenReturn(0uL) + doReturn(0uL).whenever(wallet).balance() whenever(wallet.refreshTransfers()).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertTrue(repo.refresh(history = false, force = true).isFailure) assertTrue(repo.refresh(history = false, force = true).exceptionOrNull() is UsdtException.RateLimited) verify(wallet).balance() @@ -216,7 +306,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.syncHistory()).thenReturn(true) val balance = CompletableDeferred() whenever(wallet.balance()).doSuspendableAnswer { balance.await() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val refresh = launch { repo.refresh(true, force = true).getOrThrow() } runCurrent() repo.isSendPresented = true @@ -240,7 +330,7 @@ class UsdtRepoTest : BaseUnitTest() { release.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } runCurrent() val quote = quote() @@ -275,7 +365,7 @@ class UsdtRepoTest : BaseUnitTest() { ) val deadline = Instant.parse("2026-10-12T12:00:00.123456789Z") whenever(service.send(quote, deadline)).thenReturn(submitted) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(submitted, repo.send(quote, deadline).getOrThrow()) verify(service).send(quote, deadline) assertEquals(listOf(submitted), repo.state.value.transfers) @@ -293,7 +383,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(service.send(quote)).thenAnswer { throw error } whenever(service.wallet()).thenReturn(wallet) whenever(wallet.history()).thenReturn(listOf(queued)) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) assertEquals(error, repo.send(quote).exceptionOrNull()) assertEquals(listOf(queued), repo.state.value.transfers) @@ -309,7 +399,7 @@ class UsdtRepoTest : BaseUnitTest() { release.await() emptyList() } - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) val preceding = launch { UsdtDepositRepo(repo).depositNetworks().getOrThrow() } val quote = quote(now.epochSeconds.toULong() + 120u) var authorized = false @@ -332,7 +422,7 @@ class UsdtRepoTest : BaseUnitTest() { var now = Instant.fromEpochSeconds(1_800_000_000) val clock = object : Clock { override fun now() = now } val quote = quote(now.epochSeconds.toULong() + 120u) - val repo = UsdtRepo(service, testDispatcher, clock) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher, clock) val result = repo.send(quote) { now += 120.seconds } @@ -351,7 +441,7 @@ class UsdtRepoTest : BaseUnitTest() { var history = listOf(old) whenever(wallet.history()).thenAnswer { history } whenever(wallet.refreshTransfers()).thenAnswer { history } - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(true, force = true).getOrThrow() assertTrue(repo.state.value.pendingReceipts.isEmpty()) val received = incoming("new") @@ -388,7 +478,7 @@ class UsdtRepoTest : BaseUnitTest() { whenever(wallet.balance()).thenReturn(1_000_000uL) whenever(wallet.refreshTransfers()).thenReturn(emptyList()) whenever(wallet.syncHistory()).thenReturn(false, true) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(true, force = true).getOrThrow() assertEquals(1_000_000uL, repo.state.value.balance) assertEquals(listOf(incoming("old")), repo.state.value.transfers) @@ -408,7 +498,7 @@ class UsdtRepoTest : BaseUnitTest() { val sent = incoming("sent").copy(isIncoming = false) whenever(wallet.checkRecentExecution("sent")).thenReturn(sent) whenever(wallet.checkRecentExecution("limited")).thenThrow(UsdtException.RateLimited()) - val repo = UsdtRepo(service, testDispatcher) + val repo = UsdtRepo(service, CacheStore(InMemoryDataStore(AppCacheData())), testDispatcher) repo.refresh(false).getOrThrow() repo.waitForTransfer("sent").getOrThrow() assertEquals(listOf(sent), repo.state.value.transfers) diff --git a/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt index 49f02ce6cc..4d4c13e0cb 100644 --- a/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt +++ b/app/src/test/java/to/bitkit/ui/components/UsdtDisplayTest.kt @@ -1,12 +1,53 @@ package to.bitkit.ui.components +import kotlinx.collections.immutable.persistentListOf import org.junit.Test +import to.bitkit.models.FxRate import to.bitkit.models.PaykitExchangeRate +import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.UsdtWalletState +import to.bitkit.ui.screens.wallets.usdtBalanceUpdating +import to.bitkit.ui.screens.wallets.usdtHeadlineSats import java.util.Locale import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class UsdtDisplayTest { + @Test + fun `updating remains visible until balance and rates finish`() { + val now = 1_800_000_000_000L + val currency = CurrencyState( + rates = persistentListOf( + FxRate( + symbol = "BTCUSD", lastPrice = "100000", base = "BTC", + baseName = "Bitcoin", quote = "USD", quoteName = "US Dollar", currencySymbol = "$", + currencyFlag = "", lastUpdatedAt = now - 300_000 + ) + ), + isRefreshing = true, + ) + val usdt = UsdtWalletState(balance = 1_000_000uL, balanceIsStale = false, refreshing = true) + assertTrue(usdtBalanceUpdating(usdt, currency, now)) + assertTrue(usdtBalanceUpdating(usdt.copy(refreshing = false), currency, now)) + assertFalse(usdtBalanceUpdating(usdt.copy(refreshing = false), currency.copy(isRefreshing = false), now)) + } + + @Test + fun `Home uses old cached rates without making them valid for payments`() { + val now = 1_800_000_000_000L + val oldRate = PaykitExchangeRate("100000", now - 3_600_000) + assertEquals(1234L, usdtHeadlineSats(1_234_567uL, true, oldRate, now)) + assertNull(usdtDisplaySats("1.234567", oldRate, now)) + assertNull(usdtHeadlineSats(null, true, oldRate, now)) + assertNull(usdtHeadlineSats(1uL, true, null, now)) + assertNull(usdtHeadlineSats(1uL, true, oldRate.copy(timestampMillis = now + 1), now)) + assertNull(usdtHeadlineSats(1uL, true, oldRate.copy(price = "0"), now)) + assertEquals(0L, usdtHeadlineSats(0uL, true, null, now)) + assertEquals(0L, usdtHeadlineSats(null, false, null, now)) + } + @Test fun `exact amounts preserve token precision across regions`() { listOf(Locale.US, Locale.GERMANY, Locale.forLanguageTag("nl-NL"), Locale.FRANCE).forEach { locale -> diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt index 1c0cb0bb4a..0abbe0741b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/HomeViewModelTest.kt @@ -16,6 +16,7 @@ import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.UsdtPreference import to.bitkit.data.WidgetsData import to.bitkit.env.Env import to.bitkit.models.BalanceState @@ -116,6 +117,8 @@ class HomeViewModelTest : BaseUnitTest() { @Test fun `empty state follows the enabled USDT balance`() = test { + val settings = MutableStateFlow(SettingsData(usdtPreference = UsdtPreference.ENABLED)) + whenever(settingsStore.data).thenReturn(settings) val sut = createViewModel() advanceUntilIdle() assertTrue(sut.uiState.value.showEmptyState) @@ -123,6 +126,19 @@ class HomeViewModelTest : BaseUnitTest() { usdtState.value = UsdtWalletState(balance = 50_000uL) advanceUntilIdle() assertEquals(!Env.isUsdtEnabled, sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(cachedBalance = 50_000uL) + advanceUntilIdle() + assertEquals(!Env.isUsdtEnabled, sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(balance = 0uL, cachedBalance = 50_000uL) + advanceUntilIdle() + assertTrue(sut.uiState.value.showEmptyState) + + usdtState.value = UsdtWalletState(cachedBalance = 50_000uL) + settings.value = SettingsData(usdtPreference = UsdtPreference.DISABLED) + advanceUntilIdle() + assertTrue(sut.uiState.value.showEmptyState) } @Test diff --git a/changelog.d/next/1700.fixed.md b/changelog.d/next/1700.fixed.md new file mode 100644 index 0000000000..b1edc0d228 --- /dev/null +++ b/changelog.d/next/1700.fixed.md @@ -0,0 +1 @@ +Show the last known Home balance while USDT balances and exchange rates refresh. diff --git a/journeys/usdt-cached-home-balance.md b/journeys/usdt-cached-home-balance.md new file mode 100644 index 0000000000..da45589fd7 --- /dev/null +++ b/journeys/usdt-cached-home-balance.md @@ -0,0 +1,12 @@ +# Cached USDT Home balance + +This manual recipe requires a disposable wallet with an existing USDT display snapshot and cached exchange rates, plus control over its RPC and exchange-rate connectivity. Network fault injection and aged cache preparation are not provided by the standard journey environment. No payment or funding is required: an explicitly prepared display snapshot is not an on-chain balance and must be identified as such in the test report. + +1. Record the wallet address, network, USDT amount, BTC/USD rate and Bitcoin balances in the prepared snapshot. Keep the rate more than ten minutes old and the snapshot isolated from other wallets. +2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. With a positive cached balance and no activity, the empty-wallet funding prompt must stay hidden. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. +3. Hide balances using the eye control. Both headline and USDT tile must remain hidden. Show them again and confirm the cached amounts are unchanged. +4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Keep the balance and rate responses successful while making the history endpoint fail; the updating indicator must still clear after refresh finishes. Repeat with a failed balance read; the previous amount must remain marked as updating. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate, and an otherwise empty wallet must show its funding prompt. +5. In a separate disposable install using different wallet credentials or network endpoints, the first wallet's snapshot must not appear. Do not reset an existing wallet to perform this check. +6. With only an expired rate available, open an unfunded payment amount flow and attempt conversion/review without submitting. The display cache must not authorize a quote, conversion or payment that requires a fresh rate. + +Record the app revision, device, snapshot provenance and each observed result. A seeded display snapshot proves rendering and restart behavior; it does not prove a funded transfer, provider operation or settlement.