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")
}
}