diff --git a/data/account/src/main/kotlin/com/tangem/data/account/cleaner/AccountsUserWalletDataCleaner.kt b/data/account/src/main/kotlin/com/tangem/data/account/cleaner/AccountsUserWalletDataCleaner.kt new file mode 100644 index 0000000000..a58632a93a --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/cleaner/AccountsUserWalletDataCleaner.kt @@ -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) { + // 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) } + } +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index 668808ef2a..b23898c95a 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -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 diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt index 9beea3a5eb..11d25fd763 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/AccountsResponseStoreFactory.kt @@ -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) = 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 = createdDataStores.toMap() diff --git a/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt b/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt index d289e36ca0..cebac84969 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/store/LegacyUserTokensResponseStore.kt @@ -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) { + if (userWalletIds.isEmpty()) return + + val keys = userWalletIds.map { createPreferencesKey(userWalletId = it.stringValue) } + + appPreferencesStore.updateData { preferences -> + preferences.toMutablePreferences().apply { + keys.forEach { key -> remove(key) } + } } } diff --git a/data/account/src/test/java/com/tangem/data/account/cleaner/AccountsUserWalletDataCleanerTest.kt b/data/account/src/test/java/com/tangem/data/account/cleaner/AccountsUserWalletDataCleanerTest.kt new file mode 100644 index 0000000000..a2b0a22066 --- /dev/null +++ b/data/account/src/test/java/com/tangem/data/account/cleaner/AccountsUserWalletDataCleanerTest.kt @@ -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>()) } just Runs + coEvery { legacyUserTokensResponseStore.clear(any>()) } just Runs + coEvery { eTagsStore.clear(any>()) } 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") + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt index 59a7d62ae3..201455840d 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStore.kt @@ -39,6 +39,18 @@ internal class DefaultETagsStore( appPreferencesStore.editData { it.remove(storeKey) } } + override suspend fun clear(userWalletIds: List) { + 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 { return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}") } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt index 294947dab9..47117f50e3 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt @@ -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) + /** Enumeration of possible keys for storing ETag values */ enum class Key { WalletAccounts, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStoreTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStoreTest.kt new file mode 100644 index 0000000000..2867ee7224 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/cache/etag/DefaultETagsStoreTest.kt @@ -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 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") + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index a6c6cf93dc..dca72752ec 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -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) { + 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) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt index 2e63a27f7b..2470d9263b 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStore.kt @@ -45,6 +45,9 @@ internal interface NetworksStatusesStore { /** Clear statuses of [networks] by [userWalletId] */ suspend fun clear(userWalletId: UserWalletId, networks: Set) + /** Remove all statuses of the given [userWalletIds] */ + suspend fun remove(userWalletIds: List) + /** Check if there are statuses for given [userWalletId] */ suspend fun contains(userWalletId: UserWalletId): Boolean } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt index 59058d09c2..ac7c022d9c 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/utils/DefaultNetworksCleaner.kt @@ -28,6 +28,11 @@ internal class DefaultNetworksCleaner( private val dispatchers: CoroutineDispatcherProvider, ) : NetworksCleaner { + override suspend fun clear(userWalletIds: List) { + runSuspendCatching { networksStatusesStore.remove(userWalletIds) } + .onFailure { TangemLogger.e("Failed to remove network statuses for wallets: $userWalletIds", it) } + } + override suspend fun invoke(userWalletId: UserWalletId, currencies: List) { if (currencies.isEmpty()) { TangemLogger.d("No currencies to clear for wallet: $userWalletId") diff --git a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt index 5e7d1d6083..5fcc50d679 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/utils/DefaultNetworksCleanerTest.kt @@ -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 diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt index a9763e801e..1c4cd31fb1 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/BaseStakingBalancesStore.kt @@ -32,4 +32,7 @@ interface BaseStakingBalancesStore { /** Clear staking balances */ suspend fun clear(userWalletId: UserWalletId, stakingIds: Set) + + /** Remove all staking balances for the given [userWalletIds] */ + suspend fun remove(userWalletIds: List) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt index c78065a255..f715778d92 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultP2PEthPoolBalancesStore.kt @@ -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) { + 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) { val newBalances = P2PEthPoolStakingBalanceConverter.convertAll( responses = values, diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt index d26c1dee88..946ce7b7d0 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultStakeKitBalancesStore.kt @@ -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) { + 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) { runtimeStore.update(default = emptyMap()) { stored -> stored.toMutableMap().apply { diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt index 696578c106..1f73e67a86 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/DefaultStakingCleaner.kt @@ -30,6 +30,23 @@ internal class DefaultStakingCleaner( private val dispatchers: CoroutineDispatcherProvider, ) : StakingCleaner { + override suspend fun clear(userWalletIds: List) { + 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) { if (currencies.isEmpty()) { TangemLogger.d("No currencies to clear for wallet: $userWalletId") diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt index 81e8af5f3b..45d0a64962 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/utils/DefaultStakingCleanerTest.kt @@ -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()) + } + } + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt index 9be91f042e..b43f694e05 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/di/AccountStatusUseCaseModule.kt @@ -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 } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt index 15fdc5ff07..df0dc4f4f9 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleaner.kt @@ -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) { + 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. diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleanerTest.kt similarity index 77% rename from domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt rename to domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleanerTest.kt index e1ce153ad3..9a65121535 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyCleanerTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/utils/CryptoCurrencyMetadataCleanerTest.kt @@ -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()) + } + } } \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt index 4a5ac9acd8..36b9b31b82 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/utils/NetworksCleaner.kt @@ -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) + + /** + * 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) } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt index 9e1ce60e74..31d1b308aa 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/StakingCleaner.kt @@ -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) + + /** + * 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) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 79ee5b4a30..4255fec73e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -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) } }