Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 17:08:05 +04:00
parent 017eece8b7
commit d96dd19f01
23 changed files with 456 additions and 8 deletions

View file

@ -0,0 +1,44 @@
package com.tangem.data.account.cleaner
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Clears per-wallet accounts and user tokens caches when wallets are deleted.
*
* Since [UserWalletId] is derived from the wallet key, re-adding the same wallet reuses the same id. Without this
* cleanup the stale accounts response and its ETag survive the deletion, the server answers `304 Not Modified` on
* re-add and the cached (non-empty) accounts short-circuit the fetch flow, so public key derivation never runs.
*
* @property accountsResponseStoreFactory factory owning the per-wallet accounts response stores
* @property legacyUserTokensResponseStore legacy per-wallet user tokens store
* @property eTagsStore store of caching ETags
*/
internal class AccountsUserWalletDataCleaner @Inject constructor(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val legacyUserTokensResponseStore: LegacyUserTokensResponseStore,
private val eTagsStore: ETagsStore,
) : UserWalletDataCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
// Best-effort: isolate failures per store so one failing store does not cancel the others.
coroutineScope {
launch { clearSafely(store = "accounts response") { accountsResponseStoreFactory.clear(userWalletIds) } }
launch { clearSafely(store = "legacy user tokens") { legacyUserTokensResponseStore.clear(userWalletIds) } }
launch { clearSafely(store = "ETags") { eTagsStore.clear(userWalletIds) } }
}
}
private suspend fun clearSafely(store: String, clear: suspend () -> Unit) {
runSuspendCatching { clear() }
.onFailure { TangemLogger.e("Failed to clear $store store", it) }
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.account.di
import android.content.Context
import com.tangem.data.account.cleaner.AccountsUserWalletDataCleaner
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
@ -16,6 +17,7 @@ import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -23,6 +25,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module
@ -53,6 +56,10 @@ internal object AccountDataModule {
)
}
@Provides
@IntoSet
fun provideAccountsUserWalletDataCleaner(impl: AccountsUserWalletDataCleaner): UserWalletDataCleaner = impl
@Provides
@Singleton
fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl

View file

@ -11,8 +11,12 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.wallet.UserWalletId
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@ -56,6 +60,26 @@ internal class AccountsResponseStoreFactory @Inject constructor(
}
}
/**
* Clears the persisted accounts responses for the given [userWalletIds].
*
* Each wallet owns a separate store file, so they are reset concurrently. The store instances are kept alive
* (DataStore forbids multiple instances for the same file), only their content is reset to the default `null`.
*
* @param userWalletIds the unique identifiers of the user's wallets
*/
suspend fun clear(userWalletIds: List<UserWalletId>) = coroutineScope {
// Best-effort: a failure clearing one wallet must not cancel clearing the others.
userWalletIds.forEach { userWalletId ->
launch {
runSuspendCatching { create(userWalletId).updateData { null } }
.onFailure {
TangemLogger.e("Failed to clear accounts response for wallet: ${userWalletId.stringValue}", it)
}
}
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()

View file

@ -25,10 +25,18 @@ internal class LegacyUserTokensResponseStore @Inject constructor(
}
suspend fun clear(userWalletId: UserWalletId) {
appPreferencesStore.updateData { preferences ->
val key = createPreferencesKey(userWalletId = userWalletId.stringValue)
clear(userWalletIds = listOf(userWalletId))
}
preferences.toMutablePreferences().apply { remove(key) }
suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val keys = userWalletIds.map { createPreferencesKey(userWalletId = it.stringValue) }
appPreferencesStore.updateData { preferences ->
preferences.toMutablePreferences().apply {
keys.forEach { key -> remove(key) }
}
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.data.account.cleaner
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.LegacyUserTokensResponseStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AccountsUserWalletDataCleanerTest {
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val legacyUserTokensResponseStore: LegacyUserTokensResponseStore = mockk()
private val eTagsStore: ETagsStore = mockk()
private val cleaner = AccountsUserWalletDataCleaner(
accountsResponseStoreFactory = accountsResponseStoreFactory,
legacyUserTokensResponseStore = legacyUserTokensResponseStore,
eTagsStore = eTagsStore,
)
@BeforeEach
fun resetMocks() {
clearMocks(accountsResponseStoreFactory, legacyUserTokensResponseStore, eTagsStore)
coEvery { accountsResponseStoreFactory.clear(any<List<UserWalletId>>()) } just Runs
coEvery { legacyUserTokensResponseStore.clear(any<List<UserWalletId>>()) } just Runs
coEvery { eTagsStore.clear(any<List<UserWalletId>>()) } just Runs
}
@Test
fun `GIVEN wallets WHEN clear THEN each store is cleared once with all ids in a single call`() = runTest {
// Arrange
val ids = listOf(WALLET_A, WALLET_B)
// Act
cleaner.clear(ids)
// Assert
coVerify(exactly = 1) { accountsResponseStoreFactory.clear(ids) }
coVerify(exactly = 1) { legacyUserTokensResponseStore.clear(ids) }
coVerify(exactly = 1) { eTagsStore.clear(ids) }
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

@ -39,6 +39,18 @@ internal class DefaultETagsStore(
appPreferencesStore.editData { it.remove(storeKey) }
}
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
appPreferencesStore.editData { preferences ->
userWalletIds.forEach { userWalletId ->
ETagsStore.Key.entries.forEach { key ->
preferences.remove(getAccountsETagKey(userWalletId = userWalletId, key = key))
}
}
}
}
private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key<String> {
return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}")
}

View file

@ -33,6 +33,13 @@ interface ETagsStore {
*/
suspend fun clear(userWalletId: UserWalletId, key: Key)
/**
* Clears all stored ETag values (every [Key]) for the specified wallets in a single operation
*
* @param userWalletIds identifiers of the user wallets
*/
suspend fun clear(userWalletIds: List<UserWalletId>)
/** Enumeration of possible keys for storing ETag values */
enum class Key {
WalletAccounts,

View file

@ -0,0 +1,68 @@
package com.tangem.data.common.cache.etag
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultETagsStoreTest {
private val appPreferencesStore: AppPreferencesStore = mockk()
private val store = DefaultETagsStore(appPreferencesStore = appPreferencesStore)
@BeforeEach
fun resetMocks() {
clearMocks(appPreferencesStore)
}
@Test
fun `GIVEN wallets WHEN clear list THEN all keys of all wallets removed in a single edit`() = runTest {
// Arrange
val transform = slot<suspend AppPreferencesStore.(MutablePreferences) -> Unit>()
coEvery { appPreferencesStore.editData(capture(transform)) } returns mutablePreferencesOf()
val preferences = mutablePreferencesOf().apply {
ETagsStore.Key.entries.forEach { key ->
set(stringPreferencesKey("etag_${key}_${WALLET_A.stringValue}"), "a")
set(stringPreferencesKey("etag_${key}_${WALLET_B.stringValue}"), "b")
}
set(stringPreferencesKey("unrelated"), "keep")
}
// Act
store.clear(userWalletIds = listOf(WALLET_A, WALLET_B))
transform.captured.invoke(appPreferencesStore, preferences)
// Assert
coVerify(exactly = 1) { appPreferencesStore.editData(any()) }
assertThat(preferences.asMap().keys.map(Preferences.Key<*>::name)).containsExactly("unrelated")
}
@Test
fun `GIVEN empty list WHEN clear list THEN nothing is edited`() = runTest {
// Act
store.clear(userWalletIds = emptyList())
// Assert
coVerify(exactly = 0) { appPreferencesStore.editData(any()) }
}
private companion object {
val WALLET_A = UserWalletId("011")
val WALLET_B = UserWalletId("022")
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
@ -123,6 +124,24 @@ internal class DefaultNetworksStatusesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val keys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - keys } }
.onFailure { TangemLogger.e("Failed to remove network statuses from runtime", it) }
}
launch {
runSuspendCatching { persistenceDataStore.updateData { stored -> stored - keys } }
.onFailure { TangemLogger.e("Failed to remove network statuses from persistence", it) }
}
}
}
override suspend fun contains(userWalletId: UserWalletId): Boolean {
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
}

View file

@ -45,6 +45,9 @@ internal interface NetworksStatusesStore {
/** Clear statuses of [networks] by [userWalletId] */
suspend fun clear(userWalletId: UserWalletId, networks: Set<Network>)
/** Remove all statuses of the given [userWalletIds] */
suspend fun remove(userWalletIds: List<UserWalletId>)
/** Check if there are statuses for given [userWalletId] */
suspend fun contains(userWalletId: UserWalletId): Boolean
}

View file

@ -28,6 +28,11 @@ internal class DefaultNetworksCleaner(
private val dispatchers: CoroutineDispatcherProvider,
) : NetworksCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
runSuspendCatching { networksStatusesStore.remove(userWalletIds) }
.onFailure { TangemLogger.e("Failed to remove network statuses for wallets: $userWalletIds", it) }
}
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) {
TangemLogger.d("No currencies to clear for wallet: $userWalletId")

View file

@ -8,6 +8,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -103,6 +104,22 @@ class DefaultNetworksCleanerTest {
}
}
@Test
fun `GIVEN wallets WHEN clear THEN statuses removed once and Blockchain SDK untouched`() = runTest {
// Arrange
val ids = listOf(UserWalletId("011"), UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) { networksStatusesStore.remove(ids) }
coVerifyOrder(inverse = true) {
walletManagersFacade.remove(userWalletId = any(), networks = any())
walletManagersFacade.removeTokens(userWalletId = any(), tokens = any())
}
}
@Test
fun `should handle exception during cleaning`() = runTest {
// Arrange

View file

@ -32,4 +32,7 @@ interface BaseStakingBalancesStore {
/** Clear staking balances */
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/** Remove all staking balances for the given [userWalletIds] */
suspend fun remove(userWalletIds: List<UserWalletId>)
}

View file

@ -5,6 +5,8 @@ import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConver
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
@ -108,6 +110,25 @@ internal class DefaultP2PEthPoolBalancesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val walletIds = userWalletIds.toSet()
val stringKeys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - walletIds } }
.onFailure { TangemLogger.e("Failed to remove P2PEthPool staking balances from runtime", it) }
}
launch {
runSuspendCatching { persistenceStore.updateData { stored -> stored - stringKeys } }
.onFailure { TangemLogger.e("Failed to remove P2PEthPool staking balances from persistence", it) }
}
}
}
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
val newBalances = P2PEthPoolStakingBalanceConverter.convertAll(
responses = values,

View file

@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrap
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.token.converter.StakingBalanceConverter
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
@ -99,6 +101,25 @@ internal class DefaultStakeKitBalancesStore(
}
}
override suspend fun remove(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
val walletIds = userWalletIds.toSet()
val stringKeys = userWalletIds.mapTo(hashSetOf()) { it.stringValue }
// Best-effort: attempt both runtime and persistence removal even if one fails.
coroutineScope {
launch {
runSuspendCatching { runtimeStore.update(default = emptyMap()) { stored -> stored - walletIds } }
.onFailure { TangemLogger.e("Failed to remove StakeKit staking balances from runtime", it) }
}
launch {
runSuspendCatching { persistenceStore.updateData { stored -> stored - stringKeys } }
.onFailure { TangemLogger.e("Failed to remove StakeKit staking balances from persistence", it) }
}
}
}
private suspend fun clearInRuntime(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply {

View file

@ -30,6 +30,23 @@ internal class DefaultStakingCleaner(
private val dispatchers: CoroutineDispatcherProvider,
) : StakingCleaner {
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
// Best-effort: isolate failures per store so one failing store does not cancel the other.
withContext(dispatchers.default) {
awaitAll(
async { removeSafely(store = "StakeKit") { stakeKitBalancesStore.remove(userWalletIds) } },
async { removeSafely(store = "P2PEthPool") { p2pEthPoolBalancesStore.remove(userWalletIds) } },
)
}
}
private suspend fun removeSafely(store: String, remove: suspend () -> Unit) {
runSuspendCatching { remove() }
.onFailure { TangemLogger.e("Failed to remove $store staking balances", it) }
}
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) {
TangemLogger.d("No currencies to clear for wallet: $userWalletId")

View file

@ -167,4 +167,36 @@ class DefaultStakingCleanerTest {
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ClearByWalletIds {
@Test
fun `GIVEN wallets WHEN clear THEN both balances stores removed once with all ids`() = runTest {
// Arrange
val ids = listOf(UserWalletId("011"), UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) {
stakeKitBalancesStore.remove(ids)
p2pEthPoolBalancesStore.remove(ids)
}
}
@Test
fun `GIVEN empty list WHEN clear THEN stores are not touched`() = runTest {
// Act
cleaner.clear(userWalletIds = emptyList())
// Assert
coVerify(inverse = true) {
stakeKitBalancesStore.remove(any())
p2pEthPoolBalancesStore.remove(any())
}
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
@ -32,6 +33,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton
@Module
@ -227,4 +229,10 @@ internal object AccountStatusUseCaseModule {
dispatchers = dispatchers,
)
}
@Provides
@IntoSet
fun provideCryptoCurrencyMetadataUserWalletDataCleaner(
impl: CryptoCurrencyMetadataCleaner,
): UserWalletDataCleaner = impl
}

View file

@ -1,11 +1,14 @@
package com.tangem.domain.account.status.utils
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
@ -27,7 +30,32 @@ class CryptoCurrencyMetadataCleaner(
private val stakingCleaner: StakingCleaner,
private val nftCleaner: NFTCleaner,
private val dispatchers: CoroutineDispatcherProvider,
) {
) : UserWalletDataCleaner {
/**
* Removes all currency metadata (networks, staking balances) for the given wallets.
*
* Invoked on wallet deletion, where the concrete currencies are no longer available, so cleanup happens by
* wallet id in bulk instead of per-currency.
*
* @param userWalletIds The IDs of the deleted user wallets.
*/
override suspend fun clear(userWalletIds: List<UserWalletId>) {
if (userWalletIds.isEmpty()) return
// Best-effort: isolate failures so a failing cleaner does not cancel the other.
withContext(dispatchers.default) {
awaitAll(
async { clearSafely(target = "networks") { networksCleaner.clear(userWalletIds) } },
async { clearSafely(target = "staking") { stakingCleaner.clear(userWalletIds) } },
)
}
}
private suspend fun clearSafely(target: String, clear: suspend () -> Unit) {
runSuspendCatching { clear() }
.onFailure { TangemLogger.e("Failed to clear $target metadata", it) }
}
/**
* Cleans up data for a single cryptocurrency in the specified user wallet.

View file

@ -19,7 +19,7 @@ import org.junit.jupiter.api.TestInstance
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoCurrencyCleanerTest {
class CryptoCurrencyMetadataCleanerTest {
private val networksCleaner: NetworksCleaner = mockk(relaxUnitFun = true)
private val stakingCleaner: StakingCleaner = mockk(relaxUnitFun = true)
@ -71,4 +71,31 @@ class CryptoCurrencyCleanerTest {
nftCleaner(userWalletId = userWalletId, networks = setOf(coin.network, token.network))
}
}
@Test
fun `GIVEN wallets WHEN clear THEN networks and staking cleared once with all ids`() = runTest {
// Arrange
val ids = listOf(userWalletId, UserWalletId("022"))
// Act
cleaner.clear(userWalletIds = ids)
// Assert
coVerify(exactly = 1) {
networksCleaner.clear(ids)
stakingCleaner.clear(ids)
}
}
@Test
fun `GIVEN empty list WHEN clear THEN no cleaners are called`() = runTest {
// Act
cleaner.clear(userWalletIds = emptyList())
// Assert
coVerify(inverse = true) {
networksCleaner.clear(any())
stakingCleaner.clear(any())
}
}
}

View file

@ -27,4 +27,13 @@ interface NetworksCleaner {
* @param currencies The list of cryptocurrencies whose associated network data should be cleaned.
*/
suspend operator fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
/**
* Removes all cached network data for the given [userWalletIds].
*
* Used on wallet deletion, where the concrete networks are no longer available.
*
* @param userWalletIds The IDs of the user wallets whose network data should be removed.
*/
suspend fun clear(userWalletIds: List<UserWalletId>)
}

View file

@ -46,4 +46,13 @@ interface StakingCleaner {
* @param stakingIds The set of staking IDs whose associated data should be cleaned.
*/
suspend operator fun invoke(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
/**
* Removes all cached staking balances for the given [userWalletIds].
*
* Used on wallet deletion, where the concrete staking ids are no longer available.
*
* @param userWalletIds the user wallet ids whose staking balances should be removed
*/
suspend fun clear(userWalletIds: List<UserWalletId>)
}

View file

@ -647,7 +647,9 @@ internal class WalletModel @Inject constructor(
}
private fun addWallet(action: WalletsUpdateActionResolver.Action.AddWallet) {
fetchWalletContent(userWallet = action.selectedWallet)
// Force update: a re-added wallet reuses the same id, so a stale (completed) fetch job from a previous
// session would otherwise make the fetcher skip loading, leaving the screen stuck on infinite loading.
fetchWalletContent(userWallet = action.selectedWallet, forceUpdate = true)
stateHolder.update(
AddWalletTransformer(
@ -770,7 +772,7 @@ internal class WalletModel @Inject constructor(
}
}
private fun fetchWalletContent(userWallet: UserWallet) {
private fun fetchWalletContent(userWallet: UserWallet, forceUpdate: Boolean = false) {
if (userWallet.isLocked) return
/*
@ -778,7 +780,7 @@ internal class WalletModel @Inject constructor(
* so the coroutine is launched in the current context
*/
modelScope.launch {
walletContentFetcher(userWalletId = userWallet.walletId)
walletContentFetcher(userWalletId = userWallet.walletId, forceUpdate = forceUpdate)
}
}