Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ fun HomeScreen(
onRefresh = {
activityListViewModel.resync()
walletViewModel.onPullToRefresh()
homeViewModel.refreshWidgets()
homeViewModel.onPullToRefresh()
Comment thread
ovitrif marked this conversation as resolved.
},
onRemoveSuggestion = { suggestion ->
homeViewModel.removeSuggestion(suggestion)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,9 @@ class HomeViewModel @Inject constructor(
}
}

fun refreshWidgets() {
viewModelScope.launch {
widgetsRepo.refreshEnabledWidgets()
}
fun onPullToRefresh() {
viewModelScope.launch { currencyRepo.triggerRefresh() }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
viewModelScope.launch { widgetsRepo.refreshEnabledWidgets() }
}

fun moveWidget(fromIndex: Int, toIndex: Int) {
Expand Down
32 changes: 32 additions & 0 deletions app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Unit>()
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

@Test
fun `onPullToRefresh refreshes widgets while rates refresh is pending`() = test {
val release = CompletableDeferred<Unit>()
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,
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/622.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pulling to refresh on Home now also refreshes exchange rates.
2 changes: 2 additions & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions journeys/home/pull-to-refresh-rates.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<journey name="pull to refresh rates">
<description>
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.
</description>
<actions>
<action>Run `adb shell "run-as to.bitkit.dev ls -t files/logs/"` and note the newest log file</action>
<action>Run `adb shell "run-as to.bitkit.dev grep 'Currency rates refreshed' files/logs/&lt;newest&gt;"` and note the time of the last line</action>
<action>Verify the home screen (tag "HomeScrollView") is visible</action>
<action>Run `adb shell input swipe 540 700 540 1600 400` and note the UTC time</action>
<action>Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull</action>
<action>Verify the same pull logs "Updated PRICE widget successfully" from WidgetsRepo</action>
<action>Run `adb shell input swipe 540 700 540 1600 400` twice back to back and take a screenshot</action>
<action>Verify no "Rates currently unavailable" toast is visible in the screenshot</action>
<action>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</action>
</actions>
</journey>
Loading