diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index f344344b33..3951ca4861 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -25,6 +25,7 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.BTC_SCALE import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount @@ -136,27 +137,30 @@ class CurrencyRepo @Inject constructor( private suspend fun refresh() { if (isRefreshing) return isRefreshing = true - runCatching { - val fetchedRates = currencyService.fetchLatestRates() - cacheStore.update { it.copy(cachedRates = fetchedRates) } - _currencyState.update { - it.copy( - error = null, - hasStaleData = false, - lastSuccessfulRefresh = clock.now().toEpochMilliseconds(), - ) - } - Logger.debug("Currency rates refreshed successfully", context = TAG) - }.onFailure { e -> - Logger.error("Currency rates refresh failed", e, context = TAG) - _currencyState.update { it.copy(error = e) } - - _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> - val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold - _currencyState.update { it.copy(hasStaleData = isStale) } + try { + runSuspendCatching { + val fetchedRates = currencyService.fetchLatestRates() + cacheStore.update { it.copy(cachedRates = fetchedRates) } + _currencyState.update { + it.copy( + error = null, + hasStaleData = false, + lastSuccessfulRefresh = clock.now().toEpochMilliseconds(), + ) + } + Logger.debug("Currency rates refreshed successfully", context = TAG) + }.onFailure { e -> + Logger.error("Currency rates refresh failed", e, context = TAG) + _currencyState.update { it.copy(error = e) } + + _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> + val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold + _currencyState.update { it.copy(hasStaleData = isStale) } + } } + } finally { + isRefreshing = false } - isRefreshing = false } suspend fun switchUnit() = withContext(bgDispatcher) { 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 8c4d84187b..6e6e8f41e3 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 @@ -286,7 +286,7 @@ fun HomeScreen( onRefresh = { activityListViewModel.resync() walletViewModel.onPullToRefresh() - homeViewModel.refreshWidgets() + homeViewModel.onPullToRefresh() }, onRemoveSuggestion = { suggestion -> homeViewModel.removeSuggestion(suggestion) 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 927157033b..972591e4e0 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 @@ -226,10 +226,9 @@ class HomeViewModel @Inject constructor( } } - fun refreshWidgets() { - viewModelScope.launch { - widgetsRepo.refreshEnabledWidgets() - } + fun onPullToRefresh() { + viewModelScope.launch { currencyRepo.triggerRefresh() } + viewModelScope.launch { widgetsRepo.refreshEnabledWidgets() } } fun moveWidget(fromIndex: Int, toIndex: Int) { diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index d05b3b4f28..ce7ec0bfd5 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -1,12 +1,18 @@ package to.bitkit.repositories import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +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 @@ -202,6 +208,32 @@ class CurrencyRepoTest : BaseUnitTest() { assertEquals(testRates, staleState.rates) } } + + @Test + fun `should not record caller cancellation as refresh error`() = test { + whenever(cacheStore.update(any())).thenReturn(Unit) + whenever(clock.now()).thenReturn(Clock.System.now()) + val release = CompletableDeferred() + var isFirstCall = true + whenever(currencyService.fetchLatestRates()).doSuspendableAnswer { + if (isFirstCall) { + isFirstCall = false + release.await() + } + testRates + } + + sut = createSut() + val job = launch { sut.triggerRefresh() } + job.cancelAndJoin() + + assertNull(sut.currencyState.value.error) + + sut.triggerRefresh() + + assertNull(sut.currencyState.value.error) + verify(currencyService, times(2)).fetchLatestRates() + } } private class CurrencyRepoTestError(message: String) : AppError(message) 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 d0b94d946b..ea83f7dc1f 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 @@ -3,12 +3,15 @@ package to.bitkit.ui.screens.wallets import android.content.Context import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R import to.bitkit.data.SettingsData @@ -104,6 +107,34 @@ class HomeViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.showEmptyState) } + @Test + fun `onPullToRefresh refreshes rates and widgets`() = test { + val sut = createViewModel() + + sut.onPullToRefresh() + advanceUntilIdle() + + verify(currencyRepo).triggerRefresh() + verify(widgetsRepo).refreshEnabledWidgets() + } + + @Test + fun `onPullToRefresh refreshes widgets while rates refresh is pending`() = test { + val release = CompletableDeferred() + whenever(currencyRepo.triggerRefresh()).doSuspendableAnswer { release.await() } + val sut = createViewModel() + + sut.onPullToRefresh() + advanceUntilIdle() + + verify(widgetsRepo).refreshEnabledWidgets() + + release.complete(Unit) + advanceUntilIdle() + + verify(currencyRepo).triggerRefresh() + } + private fun createViewModel() = HomeViewModel( context = context, walletRepo = walletRepo, diff --git a/changelog.d/next/622.changed.md b/changelog.d/next/622.changed.md new file mode 100644 index 0000000000..4c5b5a85a9 --- /dev/null +++ b/changelog.d/next/622.changed.md @@ -0,0 +1 @@ +Pulling to refresh on Home now also refreshes exchange rates. diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..90a4349a63 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -118,6 +118,7 @@ fixtures, push notifications) live in each suite's README. | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | +| [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | @@ -142,6 +143,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | ### Running one on iOS diff --git a/journeys/home/pull-to-refresh-rates.xml b/journeys/home/pull-to-refresh-rates.xml new file mode 100644 index 0000000000..1c0a2c8b64 --- /dev/null +++ b/journeys/home/pull-to-refresh-rates.xml @@ -0,0 +1,24 @@ + + + Pulling to refresh on Home also refreshes exchange rates, not only the wallet, activity and + widgets. Precondition: onboarded dev wallet on Home (tag "HomeScrollView" visible), the Bitcoin + Price widget enabled, and network access to the rates backend. Rates also refresh on app start + and every two minutes of polling (`Env.fxRateRefreshInterval`), so pull at least 20s after the + last "Currency rates refreshed successfully" line and well before the next polling tick, or the + log check passes for the wrong reason. The success line and any error toast are not in + `android layout`; read the app log and take a screenshot. Log files rotate into `.part_NNN.log` + files mid-run, so list the logs again before each grep and read the newest one. Android only for + now: iOS does not refresh rates on pull. + + + Run `adb shell "run-as to.bitkit.dev ls -t files/logs/"` and note the newest log file + Run `adb shell "run-as to.bitkit.dev grep 'Currency rates refreshed' files/logs/<newest>"` and note the time of the last line + Verify the home screen (tag "HomeScrollView") is visible + Run `adb shell input swipe 540 700 540 1600 400` and note the UTC time + Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull + Verify the same pull logs "Updated PRICE widget successfully" from WidgetsRepo + Run `adb shell input swipe 540 700 540 1600 400` twice back to back and take a screenshot + Verify no "Rates currently unavailable" toast is visible in the screenshot + Verify the newest log (list the logs again) gains at most one "Currency rates refreshed successfully" line for the two pulls and no "Currency rates refresh failed" line + +