Updated on 2026-08-14
This commit is contained in:
parent
017eece8b7
commit
d96dd19f01
23 changed files with 456 additions and 8 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
data/common/src/test/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStoreTest.kt
vendored
Normal file
68
data/common/src/test/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStoreTest.kt
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue