Updated on 2026-08-14
This commit is contained in:
commit
50fe9e603d
1919 changed files with 94940 additions and 15178 deletions
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -10,41 +11,21 @@ android {
|
|||
}
|
||||
dependencies {
|
||||
|
||||
implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
|
||||
|
||||
// region Project - Common
|
||||
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.res)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.card)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.tokens)
|
||||
api(projects.domain.wallets)
|
||||
api(projects.domain.visa)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Tangem dependencies
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.hot.core)
|
||||
// endregion
|
||||
|
||||
|
|
@ -53,20 +34,44 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region AndroidX libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
// region Project - Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.res)
|
||||
// endregion
|
||||
|
||||
// region Other Dependencies
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.tokens)
|
||||
implementation(projects.domain.card)
|
||||
runtimeOnly(projects.domain.visa)
|
||||
runtimeOnly(projects.domain.wallets)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Features
|
||||
api(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
|
||||
// endregion
|
||||
|
||||
// region Project - Common
|
||||
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.test.turbine)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
49
data/address-book/build.gradle.kts
Normal file
49
data/address-book/build.gradle.kts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.addressbook"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.addressBook)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.flatMap
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
|
||||
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.model.AddressBook
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.joda.time.DateTime
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultAddressBookRepository(
|
||||
private val blobStore: AddressBookBlobStore,
|
||||
private val cipher: AddressBookCipher,
|
||||
private val addressBookApi: AddressBookApi,
|
||||
private val eTagsStore: ETagsStore,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val timestampProvider: IsoTimestampProvider,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : AddressBookRepository {
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
private val logger = TangemLogger.withTag(LOG_TAG)
|
||||
|
||||
override fun getContacts(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return getContactsForWallet(userWalletId)
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun getAllContacts(): Flow<List<Contact>> {
|
||||
return userWalletsListRepository.userWallets
|
||||
.filterNotNull()
|
||||
.flatMapLatest { wallets ->
|
||||
val walletsById = wallets.associateBy { it.walletId.stringValue }
|
||||
val ids = wallets.mapTo(mutableSetOf()) { it.walletId }
|
||||
blobStore.getBlobs(ids).map { blobs ->
|
||||
blobs.flatMap { blob ->
|
||||
walletsById[blob.walletId]?.let { userWallet ->
|
||||
decryptContacts(blob, userWallet)
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
private fun getContactsForWallet(userWalletId: UserWalletId): Flow<List<Contact>> {
|
||||
return blobStore.getBlob(userWalletId).map { blob ->
|
||||
val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList()
|
||||
decryptContacts(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact> {
|
||||
return withContext(dispatchers.io) {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext emptyList()
|
||||
val userWallet = findUserWallet(blob.walletId) ?: return@withContext emptyList()
|
||||
decryptContacts(blob, userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
|
||||
withContext(dispatchers.default) {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
|
||||
val userWallet = findUserWallet(blob.walletId) ?: return@withContext null
|
||||
decryptContacts(blob, userWallet).find { it.name.value == name }
|
||||
}
|
||||
|
||||
override suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
val userWallet = findUserWallet(contact.walletId.stringValue)
|
||||
?: return@withLock AddressBookSyncError.Unknown.left()
|
||||
currentContacts(contact.walletId, userWallet).flatMap { current ->
|
||||
val merged = current.filterNot { it.id == contact.id } + contact
|
||||
persist(userWallet, AddressBook(contacts = merged))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteContact(id: ContactId): Either<AddressBookSyncError, Unit> =
|
||||
withContext(dispatchers.default) {
|
||||
writeMutex.withLock {
|
||||
userWalletsListRepository.userWalletsSync().forEach { userWallet ->
|
||||
val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach
|
||||
val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach
|
||||
if (addressBook.contacts.none { it.id == id }) return@forEach
|
||||
|
||||
val remaining = addressBook.contacts.filterNot { it.id == id }
|
||||
return@withLock persist(userWallet, addressBook.copy(contacts = remaining))
|
||||
}
|
||||
// No wallet held the contact — nothing to push, treat as success.
|
||||
Unit.right()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun syncAddressBooks(): Either<AddressBookSyncError, Unit> = withContext(dispatchers.default) {
|
||||
val wallets = userWalletsListRepository.userWalletsSync()
|
||||
// Metadata only (wallet count) — persisted to the prod log so sync activity is traceable.
|
||||
logger.i("Syncing address books for ${wallets.size} wallet(s)")
|
||||
// The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the
|
||||
// first failed chunk.
|
||||
wallets.chunked(MAX_SYNC_WALLETS)
|
||||
.fold(initial = Unit.right() as Either<AddressBookSyncError, Unit>) { acc, chunk ->
|
||||
acc.flatMap { syncWalletsChunk(chunk) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncWalletsChunk(wallets: List<UserWallet>): Either<AddressBookSyncError, Unit> {
|
||||
val request = SyncAddressBooksRequest(
|
||||
wallets = wallets.map { wallet ->
|
||||
SyncAddressBooksRequest.Wallet(
|
||||
walletId = wallet.walletId.stringValue,
|
||||
etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook),
|
||||
)
|
||||
},
|
||||
)
|
||||
return safeApiCall(
|
||||
call = {
|
||||
val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() }
|
||||
// Only wallets whose etag changed are returned; the rest keep their local copy.
|
||||
logger.i("Sync response: ${response.items.size} updated book(s) out of ${wallets.size} requested")
|
||||
response.items.forEach { item ->
|
||||
val userWalletId = UserWalletId(stringValue = item.walletId)
|
||||
// Metadata only (no keys/ciphertext/plaintext) — helps QA verify what the backend delivered
|
||||
// (esp. for cross-platform books).
|
||||
logger.i(
|
||||
"Storing synced address book for wallet ${item.walletId}: " +
|
||||
"updatedAt=${item.updatedAt}, nonceLen=${item.nonce.length}, " +
|
||||
"ciphertextLen=${item.ciphertext.length}, authTagLen=${item.authTag.length}",
|
||||
)
|
||||
blobStore.storeBlob(item.toBlob())
|
||||
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag)
|
||||
}
|
||||
Unit.right()
|
||||
},
|
||||
onError = { error ->
|
||||
logger.e("Failed to sync address books: $error")
|
||||
error.toSyncError().left()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List<Contact> {
|
||||
return cipher.decrypt(blob, userWallet).fold(
|
||||
ifLeft = { error ->
|
||||
// The cipher already logged the low-level cause; this ties the failure to the read path so QA
|
||||
// can see that a stored/synced book (e.g. one created on iOS) could not be shown to the user.
|
||||
logger.e("Skipping address book for wallet ${blob.walletId}: decrypt failed with $error")
|
||||
emptyList()
|
||||
},
|
||||
ifRight = { it.contacts },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The contacts currently stored for [userWalletId], as the base a write is merged onto.
|
||||
*
|
||||
* A missing blob means no book exists yet → an empty base, so the first contact legitimately creates it.
|
||||
* But a blob that fails to decrypt must surface as [AddressBookSyncError.DecryptionFailed] rather than an empty
|
||||
* list: treating a broken book as empty would let a merged write overwrite the (non-empty) backend copy with a
|
||||
* book built from a single new contact, wiping every existing one.
|
||||
*/
|
||||
private suspend fun currentContacts(
|
||||
userWalletId: UserWalletId,
|
||||
userWallet: UserWallet,
|
||||
): Either<AddressBookSyncError, List<Contact>> {
|
||||
val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList<Contact>().right()
|
||||
return cipher.decrypt(blob, userWallet)
|
||||
.map { it.contacts }
|
||||
.mapLeft { error ->
|
||||
logger.e(
|
||||
"Refusing to overwrite address book for wallet $userWalletId: it is stored but decrypt " +
|
||||
"failed with $error",
|
||||
)
|
||||
AddressBookSyncError.DecryptionFailed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success.
|
||||
* On any failure (encryption, network, etag conflict, …) nothing is written locally.
|
||||
*/
|
||||
private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either<AddressBookSyncError, Unit> {
|
||||
val updatedAt = DateTime.parse(timestampProvider.now())
|
||||
return cipher.encrypt(addressBook, userWallet, updatedAt)
|
||||
.mapLeft { error ->
|
||||
logger.e("Failed to encrypt address book for wallet ${userWallet.walletId}: $error")
|
||||
AddressBookSyncError.Unknown
|
||||
}
|
||||
.flatMap { blob -> pushBlob(userWallet.walletId, blob) }
|
||||
}
|
||||
|
||||
private suspend fun pushBlob(
|
||||
userWalletId: UserWalletId,
|
||||
blob: AddressBookBlob,
|
||||
): Either<AddressBookSyncError, Unit> {
|
||||
// Absent etag means the book has not been created on the backend yet → omit If-Match to create it.
|
||||
val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook)
|
||||
return safeApiCall(
|
||||
call = {
|
||||
val response = withContext(dispatchers.io) {
|
||||
addressBookApi.updateAddressBook(
|
||||
walletId = blob.walletId,
|
||||
eTag = eTag,
|
||||
body = UpdateAddressBookRequest(
|
||||
version = blob.version,
|
||||
nonce = blob.nonce,
|
||||
ciphertext = blob.ciphertext,
|
||||
authTag = blob.authTag,
|
||||
),
|
||||
).bind()
|
||||
}
|
||||
blobStore.storeBlob(blob)
|
||||
eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag)
|
||||
Unit.right()
|
||||
},
|
||||
onError = { error ->
|
||||
logger.e("Failed to push address book for wallet $userWalletId: $error")
|
||||
val syncError = error.toSyncError()
|
||||
// A 412 means the local etag is stale relative to the backend. Refresh the local blob + etag so the
|
||||
// next save attempt (user re-taps Save) starts from the current backend state. We do NOT re-push here
|
||||
// on purpose — the write stays single-shot; the conflict is still surfaced so the UI can prompt.
|
||||
if (syncError is AddressBookSyncError.Conflict) {
|
||||
syncAddressBooks()
|
||||
}
|
||||
syncError.left()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob(
|
||||
version = version,
|
||||
walletId = walletId,
|
||||
updatedAt = updatedAt,
|
||||
nonce = nonce,
|
||||
ciphertext = ciphertext,
|
||||
authTag = authTag,
|
||||
)
|
||||
|
||||
private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) {
|
||||
is ApiResponseError.HttpException -> when (code) {
|
||||
Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict
|
||||
Code.NOT_FOUND -> AddressBookSyncError.NotFound
|
||||
Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized
|
||||
Code.BAD_REQUEST -> AddressBookSyncError.BadRequest
|
||||
else -> AddressBookSyncError.Unknown
|
||||
}
|
||||
is ApiResponseError.NetworkException,
|
||||
is ApiResponseError.TimeoutException,
|
||||
-> AddressBookSyncError.Network
|
||||
is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown
|
||||
}
|
||||
|
||||
private suspend fun findUserWallet(walletId: String): UserWallet? =
|
||||
userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId }
|
||||
|
||||
private companion object {
|
||||
const val LOG_TAG = "AddressBook"
|
||||
const val MAX_SYNC_WALLETS = 20
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.tangem.data.addressbook.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.tangem.data.addressbook.DefaultAddressBookRepository
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.utils.KotlinxDataStoreSerializer
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.repository.AddressBookRepository
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object AddressBookDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddressBookBlobStore(
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): AddressBookBlobStore {
|
||||
return DefaultAddressBookBlobStore(
|
||||
dataStore = DataStoreFactory.create(
|
||||
serializer = KotlinxDataStoreSerializer(
|
||||
defaultValue = emptyMap(),
|
||||
serializer = MapSerializer(
|
||||
keySerializer = String.serializer(),
|
||||
valueSerializer = AddressBookBlob.serializer(),
|
||||
),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "address_book_blobs") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Suppress("LongParameterList")
|
||||
fun provideAddressBookRepository(
|
||||
blobStore: AddressBookBlobStore,
|
||||
cipher: AddressBookCipher,
|
||||
addressBookApi: AddressBookApi,
|
||||
eTagsStore: ETagsStore,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
timestampProvider: IsoTimestampProvider,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AddressBookRepository {
|
||||
return DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AddressBookBlobStore {
|
||||
|
||||
fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?>
|
||||
|
||||
fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>>
|
||||
|
||||
suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob?
|
||||
|
||||
suspend fun storeBlob(blob: AddressBookBlob)
|
||||
|
||||
suspend fun deleteBlob(userWalletId: UserWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal typealias AddressBookBlobs = Map<String, AddressBookBlob>
|
||||
|
||||
internal class DefaultAddressBookBlobStore(
|
||||
private val dataStore: DataStore<AddressBookBlobs>,
|
||||
) : AddressBookBlobStore {
|
||||
|
||||
override fun getBlob(userWalletId: UserWalletId): Flow<AddressBookBlob?> {
|
||||
return dataStore.data
|
||||
.map { it[userWalletId.stringValue] }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override fun getBlobs(userWalletIds: Set<UserWalletId>): Flow<List<AddressBookBlob>> {
|
||||
val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue }
|
||||
return dataStore.data
|
||||
.map { stored -> stored.filterKeys { it in ids }.values.toList() }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? {
|
||||
return getStoredBlobs()[userWalletId.stringValue]
|
||||
}
|
||||
|
||||
override suspend fun storeBlob(blob: AddressBookBlob) {
|
||||
dataStore.updateData { stored -> stored + (blob.walletId to blob) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBlob(userWalletId: UserWalletId) {
|
||||
dataStore.updateData { stored -> stored - userWalletId.stringValue }
|
||||
}
|
||||
|
||||
private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first()
|
||||
}
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
package com.tangem.data.addressbook
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.addressbook.store.AddressBookBlobStore
|
||||
import com.tangem.data.common.cache.etag.ETagsStore
|
||||
import com.tangem.datasource.api.addressbook.AddressBookApi
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest
|
||||
import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse
|
||||
import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.addressbook.crypto.AddressBookCipher
|
||||
import com.tangem.domain.addressbook.error.AddressBookCryptoError
|
||||
import com.tangem.domain.addressbook.error.AddressBookSyncError
|
||||
import com.tangem.domain.addressbook.model.AddressBook
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.addressbook.model.Contact
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.domain.addressbook.model.ContactName
|
||||
import com.tangem.domain.addressbook.time.IsoTimestampProvider
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
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 DefaultAddressBookRepositoryTest {
|
||||
|
||||
private val blobStore: AddressBookBlobStore = mockk()
|
||||
private val cipher: AddressBookCipher = mockk()
|
||||
private val addressBookApi: AddressBookApi = mockk()
|
||||
private val eTagsStore: ETagsStore = mockk(relaxed = true)
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val timestampProvider: IsoTimestampProvider = mockk()
|
||||
|
||||
private val userWallet: UserWallet = mockk {
|
||||
every { walletId } returns UserWalletId(WALLET_A)
|
||||
}
|
||||
|
||||
private val repository = DefaultAddressBookRepository(
|
||||
blobStore = blobStore,
|
||||
cipher = cipher,
|
||||
addressBookApi = addressBookApi,
|
||||
eTagsStore = eTagsStore,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
timestampProvider = timestampProvider,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider)
|
||||
every { timestampProvider.now() } returns TIMESTAMP
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
|
||||
coEvery { addressBookApi.syncAddressBooks(any()) } returns
|
||||
ApiResponse.Success(SyncAddressBooksResponse(items = emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted contacts`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN getContacts THEN does not sync on collection`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
// Sync is now triggered by the feature entry points, not on flow collection.
|
||||
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(contact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN getAllContacts THEN does not sync on collection`() = runTest {
|
||||
// Arrange
|
||||
val contact = createContact(id = "c1", name = "Alice")
|
||||
val blob = createBlob()
|
||||
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
|
||||
every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob))
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBook(listOf(contact)).right()
|
||||
|
||||
// Act
|
||||
repository.getAllContacts().first()
|
||||
|
||||
// Assert
|
||||
// Sync is now triggered by the feature entry points, not on flow collection.
|
||||
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest {
|
||||
// Arrange
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null)
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob()
|
||||
every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob)
|
||||
every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
|
||||
|
||||
// Act
|
||||
val result = repository.getContacts(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest {
|
||||
// Arrange
|
||||
val existing = createContact(id = "c1", name = "Alice")
|
||||
val added = createContact(id = "c2", name = "Bob")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(existing)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(newBlob) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(added)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(existing, added)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN etag conflict WHEN saveContact THEN re-syncs once without retrying the write`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED)
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left())
|
||||
coVerify(exactly = 1) { addressBookApi.syncAddressBooks(any()) }
|
||||
coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN network error WHEN saveContact THEN does not re-sync`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { addressBookApi.syncAddressBooks(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest {
|
||||
// Arrange
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null
|
||||
every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c1", name = "Alice"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Network.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored book that fails to decrypt WHEN saveContact THEN returns DecryptionFailed and pushes nothing`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left()
|
||||
|
||||
// Act
|
||||
val result = repository.saveContact(createContact(id = "c2", name = "Bob"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.DecryptionFailed.left())
|
||||
// The broken book is left untouched: nothing is encrypted, pushed, or stored.
|
||||
coVerify(exactly = 0) { addressBookApi.updateAddressBook(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest {
|
||||
// Arrange
|
||||
val original = createContact(id = "c1", name = "Alice")
|
||||
val updated = createContact(id = "c1", name = "Alice Updated")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(original)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right()
|
||||
coEvery { blobStore.storeBlob(any()) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
repository.saveContact(updated)
|
||||
|
||||
// Assert
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest {
|
||||
// Arrange
|
||||
val kept = createContact(id = "c1", name = "Alice")
|
||||
val removed = createContact(id = "c2", name = "Bob")
|
||||
val storedBlob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob
|
||||
every { cipher.decrypt(storedBlob, userWallet) } returns
|
||||
AddressBook(listOf(kept, removed)).right()
|
||||
val bookSlot = slot<AddressBook>()
|
||||
val newBlob = createBlob()
|
||||
every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right()
|
||||
coEvery { blobStore.storeBlob(newBlob) } returns Unit
|
||||
coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse()
|
||||
|
||||
// Act
|
||||
val result = repository.deleteContact(ContactId("c2"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(bookSlot.captured.contacts).containsExactly(kept)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(newBlob) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest {
|
||||
// Arrange
|
||||
val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) }
|
||||
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB)
|
||||
val requestSlot = slot<SyncAddressBooksRequest>()
|
||||
// Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy.
|
||||
coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns
|
||||
ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A))))
|
||||
val blobSlot = slot<AddressBookBlob>()
|
||||
coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit
|
||||
|
||||
// Act
|
||||
val result = repository.syncAddressBooks()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(Unit.right())
|
||||
assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B)
|
||||
assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A)
|
||||
coVerify(exactly = 1) { blobStore.storeBlob(any()) }
|
||||
coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) }
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest {
|
||||
// Arrange
|
||||
coEvery { addressBookApi.syncAddressBooks(any()) } returns
|
||||
errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED)
|
||||
|
||||
// Act
|
||||
val result = repository.syncAddressBooks()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left())
|
||||
coVerify(exactly = 0) { blobStore.storeBlob(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest {
|
||||
// Arrange
|
||||
val alice = createContact(id = "c1", name = "Alice")
|
||||
val bob = createContact(id = "c2", name = "Bob")
|
||||
val blob = createBlob()
|
||||
coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob
|
||||
every { cipher.decrypt(blob, userWallet) } returns
|
||||
AddressBook(listOf(alice, bob)).right()
|
||||
|
||||
// Act
|
||||
val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob")
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(bob)
|
||||
}
|
||||
|
||||
private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse<UpdateAddressBookResponse> =
|
||||
ApiResponse.Success(
|
||||
data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP),
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse<T> =
|
||||
ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
|
||||
) as ApiResponse<T>
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> networkErrorResponse(): ApiResponse<T> =
|
||||
ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse<T>
|
||||
|
||||
private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item(
|
||||
walletId = walletId,
|
||||
etag = ETAG_NEW,
|
||||
version = AddressBookBlob.CURRENT_VERSION,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact(
|
||||
id = ContactId(id),
|
||||
walletId = UserWalletId(WALLET_A),
|
||||
name = ContactName(name).getOrNull()!!,
|
||||
icon = "",
|
||||
iconColor = iconColor,
|
||||
createdAt = TIMESTAMP,
|
||||
updatedAt = TIMESTAMP,
|
||||
addresses = emptyList(),
|
||||
)
|
||||
|
||||
private fun createBlob(): AddressBookBlob = AddressBookBlob(
|
||||
walletId = WALLET_A,
|
||||
updatedAt = TIMESTAMP,
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_A = "0a0a0a"
|
||||
const val WALLET_B = "0b0b0b"
|
||||
const val TIMESTAMP = "2026-05-22T09:00:00.000Z"
|
||||
const val ETAG_OLD = "etag-old"
|
||||
const val ETAG_NEW = "etag-new"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.tangem.data.addressbook.store
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.addressbook.model.AddressBookBlob
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
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 DefaultAddressBookBlobStoreTest {
|
||||
|
||||
private lateinit var store: DefaultAddressBookBlobStore
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
store = DefaultAddressBookBlobStore(
|
||||
dataStore = MockStateDataStore(default = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
|
||||
// Act
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob)
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlob(UserWalletId(WALLET_A)).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest {
|
||||
// Arrange
|
||||
val blobA = createBlob(walletId = WALLET_A)
|
||||
val blobB = createBlob(walletId = WALLET_B)
|
||||
store.storeBlob(blobA)
|
||||
store.storeBlob(blobB)
|
||||
|
||||
// Act
|
||||
val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first()
|
||||
val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(blobA, blobB)
|
||||
assertThat(onlyA).containsExactly(blobA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest {
|
||||
// Arrange
|
||||
val blob = createBlob(walletId = WALLET_A)
|
||||
store.storeBlob(blob)
|
||||
|
||||
// Act
|
||||
store.deleteBlob(UserWalletId(WALLET_A))
|
||||
|
||||
// Assert
|
||||
assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull()
|
||||
assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull()
|
||||
}
|
||||
|
||||
private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob(
|
||||
walletId = walletId,
|
||||
updatedAt = "2026-05-22T09:00:00.000Z",
|
||||
nonce = "00112233445566778899aabb",
|
||||
ciphertext = "deadbeef",
|
||||
authTag = "cafebabecafebabecafebabecafebabe",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_A = "0a0a0a"
|
||||
const val WALLET_B = "0b0b0b"
|
||||
}
|
||||
}
|
||||
|
|
@ -11,26 +11,27 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.analytics)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Analytics */
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** AndroidX */
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.analytics)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,26 +11,31 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.appCurrency)
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,23 +11,27 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.appTheme)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/** Local storages */
|
||||
implementation(deps.androidx.datastore)
|
||||
// region Project - Domain
|
||||
api(projects.domain.appTheme)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
// endregion
|
||||
}
|
||||
1
data/app-update/.gitignore
vendored
Normal file
1
data/app-update/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
27
data/app-update/build.gradle.kts
Normal file
27
data/app-update/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.appupdate"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.appUpdate)
|
||||
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.data.appupdate
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.ApplicationVersionsResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
|
||||
import com.tangem.datasource.local.preferences.utils.store
|
||||
import com.tangem.datasource.local.preferences.utils.storeObject
|
||||
import com.tangem.domain.appupdate.model.AppVersionInfo
|
||||
import com.tangem.domain.appupdate.model.OptionalUpdateShown
|
||||
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultAppUpdateRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val currentTimeMillis: () -> Long = System::currentTimeMillis,
|
||||
) : AppUpdateRepository {
|
||||
|
||||
override suspend fun getCachedAppVersionInfo(): AppVersionInfo? = appPreferencesStore
|
||||
.getObjectSyncOrNull<ApplicationVersionsResponse>(PreferencesKeys.CACHED_APP_VERSIONS_KEY)
|
||||
?.toDomain()
|
||||
|
||||
override suspend fun getCachedAppVersionTimestamp(): Long? =
|
||||
appPreferencesStore.getSyncOrNull(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY)
|
||||
|
||||
override suspend fun refreshAppVersionInfo(): Either<Throwable, AppVersionInfo> = withContext(dispatchers.io) {
|
||||
Either.catch {
|
||||
val response = tangemTechApi.getApplicationVersions().getOrThrow()
|
||||
appPreferencesStore.storeObject(PreferencesKeys.CACHED_APP_VERSIONS_KEY, response)
|
||||
appPreferencesStore.store(PreferencesKeys.CACHED_APP_VERSIONS_AT_KEY, currentTimeMillis())
|
||||
response.toDomain()
|
||||
}.onLeft { error -> TangemLogger.e("Unable to fetch application versions", error) }
|
||||
}
|
||||
|
||||
override suspend fun getOptionalUpdateShown(): OptionalUpdateShown? {
|
||||
val version = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY)
|
||||
?: return null
|
||||
val shownAtMillis = appPreferencesStore.getSyncOrNull(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY)
|
||||
?: return null
|
||||
|
||||
return OptionalUpdateShown(version = version, shownAtMillis = shownAtMillis)
|
||||
}
|
||||
|
||||
override suspend fun setOptionalUpdateShown(shown: OptionalUpdateShown) {
|
||||
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_VERSION_KEY, shown.version)
|
||||
appPreferencesStore.store(PreferencesKeys.LAST_OPTIONAL_UPDATE_SHOWN_AT_KEY, shown.shownAtMillis)
|
||||
}
|
||||
|
||||
private fun ApplicationVersionsResponse.toDomain() = AppVersionInfo(
|
||||
minSupportedVersion = minSupportedVersion,
|
||||
minSupportedOSVersion = minSupportedOSVersion,
|
||||
criticalVersion = criticalVersion,
|
||||
criticalOSVersion = criticalOSVersion,
|
||||
latestVersion = latestVersion,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.tangem.data.appupdate.di
|
||||
|
||||
import com.tangem.data.appupdate.DefaultAppUpdateRepository
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.appupdate.repository.AppUpdateRepository
|
||||
import com.tangem.domain.appupdate.usecase.GetAppUpdateStateUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object AppUpdateDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppUpdateRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): AppUpdateRepository = DefaultAppUpdateRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetAppUpdateStateUseCase(
|
||||
repository: AppUpdateRepository,
|
||||
appInfoProvider: AppInfoProvider,
|
||||
): GetAppUpdateStateUseCase = GetAppUpdateStateUseCase(
|
||||
repository = repository,
|
||||
appInfoProvider = appInfoProvider,
|
||||
)
|
||||
}
|
||||
|
|
@ -11,10 +11,17 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
implementation(projects.domain.appsflyer)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.appsflyer)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,25 +1,18 @@
|
|||
package com.tangem.data.appsflyer
|
||||
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||
import javax.inject.Inject
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
|
||||
|
||||
internal class DefaultAppsFlyerRepository @Inject constructor(
|
||||
private val appsFlyerStore: AppsFlyerStore,
|
||||
) : AppsFlyerRepository {
|
||||
|
||||
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
|
||||
return appsFlyerStore.getDeeplink(source.toStoreSource())
|
||||
override suspend fun getDeeplink(): String? {
|
||||
return appsFlyerStore.getNavigationDeeplink()
|
||||
}
|
||||
|
||||
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
|
||||
appsFlyerStore.clearDeeplink(source.toStoreSource())
|
||||
}
|
||||
|
||||
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
|
||||
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
|
||||
AppsFlyerDeeplinkSource.Referral -> StoreDeeplinkSource.Referral
|
||||
override suspend fun clearDeeplink() {
|
||||
appsFlyerStore.clearNavigationDeeplink()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,27 +11,45 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.assetsdiscovery)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.walletManager)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
implementation(projects.data.walletManager)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.assetsdiscovery)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,18 +11,29 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.androidx.appCompat)
|
||||
|
||||
/** DI */
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.lifecycle.runtime.ktx)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.balanceHiding)
|
||||
// region Domain
|
||||
api(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,35 +10,43 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/* Project - Domain */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.blockaid)
|
||||
implementation(projects.domain.blockaid.models)
|
||||
|
||||
/* Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/* Project - Core */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
api(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
/* DI */
|
||||
// region Tangem libraries
|
||||
api(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/* Tangem libraries */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
// region Domain
|
||||
api(projects.domain.blockaid)
|
||||
api(projects.domain.blockaid.models)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
/* Tests */
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,27 +11,40 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
implementation(tangemDeps.blockchain) {
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
api(deps.androidx.fragment)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDK
|
||||
api(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
api(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(tangemDeps.card.android)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
// region Domain
|
||||
api(projects.domain.card)
|
||||
api(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(projects.test.core)
|
||||
// end
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,41 +9,56 @@ android {
|
|||
namespace = "com.tangem.data.common"
|
||||
}
|
||||
dependencies {
|
||||
/* Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/* Domain */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets)
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/* Libs - SDK */
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Other libraries
|
||||
api(deps.arrow.core)
|
||||
api(deps.jodatime)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
// endregion
|
||||
|
||||
// region Libs - SDK
|
||||
api(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(projects.libs.crypto)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
/* DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/* Libs - Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
/* Test */
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.core)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.demo.models)
|
||||
api(projects.domain.express.models)
|
||||
api(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.moshi)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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,9 +33,17 @@ 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,
|
||||
UserTokens,
|
||||
AddressBook,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.FeePaidCurrency
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.card.common.extensions.canHandleToken
|
||||
|
|
@ -217,173 +218,6 @@ class NetworkFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
|
||||
return when (this) {
|
||||
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
|
||||
Blockchain.Binance,
|
||||
Blockchain.TON,
|
||||
Blockchain.Cosmos,
|
||||
Blockchain.Gonka,
|
||||
Blockchain.TerraV1,
|
||||
Blockchain.TerraV2,
|
||||
Blockchain.Stellar,
|
||||
Blockchain.Hedera,
|
||||
Blockchain.Algorand,
|
||||
Blockchain.Sei,
|
||||
Blockchain.InternetComputer,
|
||||
Blockchain.Casper,
|
||||
-> Network.TransactionExtrasType.MEMO
|
||||
// region Other blockchains
|
||||
Blockchain.Unknown,
|
||||
Blockchain.Alephium,
|
||||
Blockchain.AlephiumTestnet,
|
||||
Blockchain.Arbitrum,
|
||||
Blockchain.ArbitrumTestnet,
|
||||
Blockchain.Avalanche,
|
||||
Blockchain.AvalancheTestnet,
|
||||
Blockchain.BinanceTestnet,
|
||||
Blockchain.BSC,
|
||||
Blockchain.BSCTestnet,
|
||||
Blockchain.Bitcoin,
|
||||
Blockchain.BitcoinTestnet,
|
||||
Blockchain.BitcoinCash,
|
||||
Blockchain.BitcoinCashTestnet,
|
||||
Blockchain.Cardano,
|
||||
Blockchain.CosmosTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.Ducatus,
|
||||
Blockchain.Ethereum,
|
||||
Blockchain.EthereumTestnet,
|
||||
Blockchain.EthereumClassic,
|
||||
Blockchain.EthereumClassicTestnet,
|
||||
Blockchain.Fantom,
|
||||
Blockchain.FantomTestnet,
|
||||
Blockchain.Litecoin,
|
||||
Blockchain.Near,
|
||||
Blockchain.NearTestnet,
|
||||
Blockchain.Polkadot,
|
||||
Blockchain.PolkadotTestnet,
|
||||
Blockchain.Kava,
|
||||
Blockchain.KavaTestnet,
|
||||
Blockchain.Kusama,
|
||||
Blockchain.Polygon,
|
||||
Blockchain.PolygonTestnet,
|
||||
Blockchain.RSK,
|
||||
Blockchain.SeiTestnet,
|
||||
Blockchain.StellarTestnet,
|
||||
Blockchain.Solana,
|
||||
Blockchain.SolanaTestnet,
|
||||
Blockchain.Tezos,
|
||||
Blockchain.Tron,
|
||||
Blockchain.TronTestnet,
|
||||
Blockchain.Gnosis,
|
||||
Blockchain.Dash,
|
||||
Blockchain.Optimism,
|
||||
Blockchain.OptimismTestnet,
|
||||
Blockchain.Dischain,
|
||||
Blockchain.EthereumPow,
|
||||
Blockchain.EthereumPowTestnet,
|
||||
Blockchain.Kaspa,
|
||||
Blockchain.KaspaTestnet,
|
||||
Blockchain.Telos,
|
||||
Blockchain.TelosTestnet,
|
||||
Blockchain.TONTestnet,
|
||||
Blockchain.Ravencoin,
|
||||
Blockchain.Clore,
|
||||
Blockchain.RavencoinTestnet,
|
||||
Blockchain.Cronos,
|
||||
Blockchain.AlephZero,
|
||||
Blockchain.AlephZeroTestnet,
|
||||
Blockchain.OctaSpace,
|
||||
Blockchain.OctaSpaceTestnet,
|
||||
Blockchain.Chia,
|
||||
Blockchain.ChiaTestnet,
|
||||
Blockchain.Decimal,
|
||||
Blockchain.DecimalTestnet,
|
||||
Blockchain.XDC,
|
||||
Blockchain.XDCTestnet,
|
||||
Blockchain.VeChain,
|
||||
Blockchain.VeChainTestnet,
|
||||
Blockchain.Aptos,
|
||||
Blockchain.AptosTestnet,
|
||||
Blockchain.Playa3ull,
|
||||
Blockchain.Shibarium,
|
||||
Blockchain.ShibariumTestnet,
|
||||
Blockchain.AlgorandTestnet,
|
||||
Blockchain.HederaTestnet,
|
||||
Blockchain.Aurora,
|
||||
Blockchain.AuroraTestnet,
|
||||
Blockchain.Areon,
|
||||
Blockchain.AreonTestnet,
|
||||
Blockchain.PulseChain,
|
||||
Blockchain.PulseChainTestnet,
|
||||
Blockchain.ZkSyncEra,
|
||||
Blockchain.ZkSyncEraTestnet,
|
||||
Blockchain.Nexa,
|
||||
Blockchain.NexaTestnet,
|
||||
Blockchain.Moonbeam,
|
||||
Blockchain.MoonbeamTestnet,
|
||||
Blockchain.Manta,
|
||||
Blockchain.MantaTestnet,
|
||||
Blockchain.PolygonZkEVM,
|
||||
Blockchain.PolygonZkEVMTestnet,
|
||||
Blockchain.Radiant,
|
||||
Blockchain.Fact0rn,
|
||||
Blockchain.Base,
|
||||
Blockchain.BaseTestnet,
|
||||
Blockchain.Moonriver,
|
||||
Blockchain.MoonriverTestnet,
|
||||
Blockchain.Mantle,
|
||||
Blockchain.MantleTestnet,
|
||||
Blockchain.Flare,
|
||||
Blockchain.FlareTestnet,
|
||||
Blockchain.Taraxa,
|
||||
Blockchain.TaraxaTestnet,
|
||||
Blockchain.Koinos,
|
||||
Blockchain.KoinosTestnet,
|
||||
Blockchain.Joystream,
|
||||
Blockchain.Bittensor,
|
||||
Blockchain.Filecoin,
|
||||
Blockchain.Blast,
|
||||
Blockchain.BlastTestnet,
|
||||
Blockchain.Cyber,
|
||||
Blockchain.CyberTestnet,
|
||||
Blockchain.Sui,
|
||||
Blockchain.SuiTestnet,
|
||||
Blockchain.EnergyWebChain,
|
||||
Blockchain.EnergyWebChainTestnet,
|
||||
Blockchain.EnergyWebX,
|
||||
Blockchain.EnergyWebXTestnet,
|
||||
Blockchain.CasperTestnet,
|
||||
Blockchain.Core,
|
||||
Blockchain.CoreTestnet,
|
||||
Blockchain.Xodex,
|
||||
Blockchain.Canxium,
|
||||
Blockchain.Chiliz,
|
||||
Blockchain.ChilizTestnet,
|
||||
Blockchain.VanarChain,
|
||||
Blockchain.VanarChainTestnet,
|
||||
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
|
||||
Blockchain.Bitrock, Blockchain.BitrockTestnet,
|
||||
Blockchain.Sonic, Blockchain.SonicTestnet,
|
||||
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
|
||||
Blockchain.Scroll, Blockchain.ScrollTestnet,
|
||||
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
|
||||
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
|
||||
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
|
||||
Blockchain.Quai, Blockchain.QuaiTestnet,
|
||||
Blockchain.Linea, Blockchain.LineaTestnet,
|
||||
Blockchain.ArbitrumNova,
|
||||
Blockchain.Plasma, Blockchain.PlasmaTestnet,
|
||||
Blockchain.Adi, Blockchain.AdiTestnet,
|
||||
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
|
||||
Blockchain.Monad, Blockchain.MonadTestnet,
|
||||
-> Network.TransactionExtrasType.NONE
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
|
||||
return when (this) {
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.data.common.txhistory
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
|
||||
/**
|
||||
* Persists express (exchange/onramp) transactions into the local tx-history database and fetches any missing token
|
||||
* metadata for the referenced assets.
|
||||
*/
|
||||
interface ExpressHistoryRepository {
|
||||
|
||||
suspend fun storeExchanges(items: List<ExchangeItemResponse>)
|
||||
|
||||
suspend fun storeOnramps(items: List<OnrampItemResponse>)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -387,6 +387,7 @@ class NetworkFactoryTest {
|
|||
Blockchain.CosmosTestnet,
|
||||
Blockchain.Dogecoin,
|
||||
Blockchain.Ducatus,
|
||||
Blockchain.Gonka,
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet,
|
||||
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
|
||||
Blockchain.Fantom, Blockchain.FantomTestnet,
|
||||
|
|
|
|||
|
|
@ -9,28 +9,17 @@ android {
|
|||
namespace = "com.tangem.data.dynamicaddresses"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.dynamicAddresses)
|
||||
implementation(projects.domain.dynamicAddresses.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -38,7 +27,29 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.dynamicAddresses)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.dynamicAddresses.models)
|
||||
// endregion
|
||||
|
||||
// region Testing
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -10,23 +10,14 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.earn)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.account.status)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -34,10 +25,24 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.earn)
|
||||
implementation(projects.domain.models)
|
||||
runtimeOnly(projects.domain.account.status)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -13,26 +12,35 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.txhistory)
|
||||
api(projects.domain.models)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.express)
|
||||
api(projects.domain.express.models)
|
||||
api(projects.domain.txhistory)
|
||||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -95,6 +95,26 @@ internal class DefaultExpressServiceFetcher @Inject constructor(
|
|||
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
|
||||
}
|
||||
|
||||
override suspend fun getOrFetch(
|
||||
userWalletId: UserWalletId,
|
||||
assetId: ExpressAsset.ID,
|
||||
): Either<Throwable, ExpressAsset> = either {
|
||||
// Return the already-loaded asset (in-memory status or persisted cache) without hitting the network.
|
||||
findLoadedAsset(userWalletId, assetId)?.let { return@either it }
|
||||
|
||||
// Not loaded yet — fetch it from the API, then take it from the refreshed status.
|
||||
fetch(userWalletId = userWalletId, assetIds = setOf(assetId)).bind()
|
||||
|
||||
findLoadedAsset(userWalletId, assetId)
|
||||
?: raise(IllegalStateException("Express asset $assetId is not available after fetch"))
|
||||
}
|
||||
|
||||
private suspend fun findLoadedAsset(userWalletId: UserWalletId, assetId: ExpressAsset.ID): ExpressAsset? {
|
||||
return getInitializationStatusInternal(userWalletId).value
|
||||
.getOrNull()
|
||||
?.firstOrNull { it.id == assetId }
|
||||
}
|
||||
|
||||
@Suppress("SuspendFunWithFlowReturnType")
|
||||
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
|
||||
val initializationStatus = initializationStatuses.value[userWalletId]
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region AndroidX libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -24,27 +25,23 @@ dependencies {
|
|||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// Other libraries
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.navigation)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// Feature modules
|
||||
implementation(projects.features.hotWallet.api)
|
||||
|
||||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.legacy)
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.feedback)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles(
|
|||
) : FeedbackFeatureToggles {
|
||||
|
||||
override val isUsedeskEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED)
|
||||
get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED)
|
||||
}
|
||||
|
|
@ -11,17 +11,29 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.hotWallet)
|
||||
implementation(projects.domain.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.hotWallet)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -13,44 +12,52 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Libs */
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.demo)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.manageTokens)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.legacy)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.tokens)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Tangem SDKs */
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
/** AndroidX */
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.core)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
runtimeOnly(projects.data.tokens)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.manageTokens)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain models
|
||||
implementation(projects.domain.manageTokens.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.lib.crypto.derivation.supportsDerivationPath
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -217,6 +219,20 @@ internal class DefaultCustomTokensRepository(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun isDerivationPathSupported(
|
||||
userWalletId: UserWalletId,
|
||||
networkId: Network.ID,
|
||||
derivationPath: Network.DerivationPath,
|
||||
): Boolean = withContext(dispatchers.io) {
|
||||
val rawPath = derivationPath.value ?: return@withContext true
|
||||
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
|
||||
val blockchain = networkId.toBlockchain()
|
||||
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@withContext false
|
||||
val path = runCatching { DerivationPath(rawPath) }.getOrNull() ?: return@withContext false
|
||||
|
||||
curve.supportsDerivationPath(path)
|
||||
}
|
||||
|
||||
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
|
||||
when (val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)) {
|
||||
is UserWallet.Hot -> {
|
||||
|
|
|
|||
30
data/marketing/build.gradle.kts
Normal file
30
data/marketing/build.gradle.kts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.marketing"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.androidx.datastore)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
implementation(projects.domain.marketing)
|
||||
implementation(projects.domain.marketing.models)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.configToggles)
|
||||
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.tangem.data.marketing
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.marketing.converter.MarketingCampaignConverter
|
||||
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
|
||||
import com.tangem.data.marketing.store.MarketingDismissStore
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ETAG_HEADER
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.domain.marketing.MarketingRepository
|
||||
import com.tangem.domain.marketing.models.MarketingCampaign
|
||||
import com.tangem.domain.marketing.models.MarketingScreen
|
||||
import com.tangem.domain.marketing.models.MarketingScreenType
|
||||
import com.tangem.utils.SupportedLanguages
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultMarketingRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val cacheStore: MarketingCampaignsCacheStore,
|
||||
private val dismissStore: MarketingDismissStore,
|
||||
private val converter: MarketingCampaignConverter,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MarketingRepository {
|
||||
|
||||
// In-memory per-session cache for background (cacheable) types. Serves repeated reads within a session
|
||||
// without hitting the network; DataStore ETag cache remains the cross-session layer inside fetchAndCacheByType.
|
||||
private val sessionCache = MutableStateFlow<Map<MarketingScreenType, List<MarketingCampaign>>>(emptyMap())
|
||||
private val cacheMutex = Mutex()
|
||||
|
||||
override suspend fun getCampaigns(screen: MarketingScreen): Either<Throwable, List<MarketingCampaign>> =
|
||||
withContext(dispatchers.io) {
|
||||
Either.catch {
|
||||
if (screen.type.isCacheable) {
|
||||
loadCacheableByType(screen.type)
|
||||
} else {
|
||||
// swap/onramp — always fresh, never cached
|
||||
when (val response = requestCampaigns(screen, eTag = null)) {
|
||||
is ApiResponse.Success -> convert(response.data)
|
||||
is ApiResponse.Error -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun prefetchBackgroundCampaigns(type: MarketingScreenType) {
|
||||
if (!type.isCacheable) return
|
||||
try {
|
||||
loadCacheableByType(type)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Fire-and-forget warm-up: failures are non-fatal, the next getCampaigns() call will retry.
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDismissedBannerIds(): Set<Int> = dismissStore.getDismissedIds()
|
||||
|
||||
override suspend fun dismissBanner(campaignId: Int) = dismissStore.dismiss(campaignId)
|
||||
|
||||
private suspend fun loadCacheableByType(type: MarketingScreenType): List<MarketingCampaign> {
|
||||
sessionCache.value[type]?.let { return it }
|
||||
return cacheMutex.withLock {
|
||||
sessionCache.value[type]?.let { return@withLock it } // double-check under lock
|
||||
val result = fetchAndCacheByType(type)
|
||||
// Only cache authoritative results. A pure error fallback (error + no DataStore cache -> null)
|
||||
// must NOT poison the session cache, so a later screen open still retries the network.
|
||||
if (result != null) {
|
||||
sessionCache.update { it + (type to result) }
|
||||
}
|
||||
result.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndCacheByType(type: MarketingScreenType): List<MarketingCampaign>? {
|
||||
val cached = cacheStore.get(type.value)
|
||||
return when (val response = requestByType(type, eTag = cached?.eTag)) {
|
||||
is ApiResponse.Success -> {
|
||||
// eTag may be null if the server omits it; we still cache the body for the 5xx
|
||||
// fallback path. A null eTag simply means the next request sends no If-None-Match
|
||||
// (Retrofit omits null headers) and receives a fresh 200.
|
||||
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
|
||||
cacheStore.store(type.value, MarketingCampaignsCacheEntry(eTag, response.data))
|
||||
convert(response.data) // authoritative (may be empty = real "no banners")
|
||||
}
|
||||
// Cached fallback is authoritative-ish; null when there is nothing cached (do not session-cache).
|
||||
is ApiResponse.Error -> cached?.response?.let(::convert)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestByType(
|
||||
type: MarketingScreenType,
|
||||
eTag: String?,
|
||||
): ApiResponse<MarketingCampaignsResponse> {
|
||||
return tangemTechApi.getMarketingCampaigns(
|
||||
type = type.value,
|
||||
language = SupportedLanguages.getCurrentSupportedLanguageCode(),
|
||||
eTag = eTag,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun requestCampaigns(
|
||||
screen: MarketingScreen,
|
||||
eTag: String?,
|
||||
): ApiResponse<MarketingCampaignsResponse> {
|
||||
val language = SupportedLanguages.getCurrentSupportedLanguageCode()
|
||||
return when (screen) {
|
||||
is MarketingScreen.Swap -> tangemTechApi.getMarketingCampaigns(
|
||||
type = screen.type.value,
|
||||
language = language,
|
||||
fromNetwork = screen.fromNetwork,
|
||||
// Omit the contract for a native coin (blank) — Retrofit drops null query params.
|
||||
fromContractAddress = screen.fromContractAddress.ifBlank { null },
|
||||
toNetwork = screen.toNetwork,
|
||||
toContractAddress = screen.toContractAddress.ifBlank { null },
|
||||
)
|
||||
is MarketingScreen.Onramp -> tangemTechApi.getMarketingCampaigns(
|
||||
type = screen.type.value,
|
||||
language = language,
|
||||
fromFiat = screen.fromFiat,
|
||||
toNetwork = screen.toNetwork,
|
||||
toContractAddress = screen.toContractAddress.ifBlank { null },
|
||||
)
|
||||
is MarketingScreen.TokenDetails,
|
||||
is MarketingScreen.TokenMarkets,
|
||||
is MarketingScreen.Staking,
|
||||
is MarketingScreen.Yield,
|
||||
-> requestByType(screen.type, eTag)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convert(response: MarketingCampaignsResponse): List<MarketingCampaign> =
|
||||
converter.convertListIgnoreErrors(response.campaigns) { throwable ->
|
||||
TangemLogger.w("Skipped invalid marketing campaign: ${throwable.message}")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.tangem.data.marketing.converter
|
||||
|
||||
import com.tangem.datasource.api.marketing.models.BannerDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignTokenDto
|
||||
import com.tangem.domain.marketing.models.MarketingBanner
|
||||
import com.tangem.domain.marketing.models.MarketingCampaign
|
||||
import com.tangem.domain.marketing.models.MarketingCampaignTarget
|
||||
import com.tangem.domain.marketing.models.MarketingScreenType
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class MarketingCampaignConverter : Converter<CampaignDto, MarketingCampaign> {
|
||||
|
||||
override fun convert(value: CampaignDto): MarketingCampaign {
|
||||
val type = requireNotNull(MarketingScreenType.fromValue(value.type)) { "Unknown campaign type: ${value.type}" }
|
||||
val banner = convertBanner(value.banner)
|
||||
|
||||
require(banner.uiType != MarketingBanner.UiType.LINKED_TO_PROVIDER || !value.providerIds.isNullOrEmpty()) {
|
||||
"linked_to_provider campaign ${value.id} has no providerIds"
|
||||
}
|
||||
|
||||
return MarketingCampaign(
|
||||
id = value.id,
|
||||
type = type,
|
||||
priority = value.priority,
|
||||
startAt = value.startAt,
|
||||
endAt = value.endAt,
|
||||
minAmount = value.minAmount,
|
||||
maxAmount = value.maxAmount,
|
||||
providerIds = value.providerIds,
|
||||
banner = banner,
|
||||
targets = value.tokens.orEmpty().mapNotNull(::convertTarget),
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertBanner(dto: BannerDto) = MarketingBanner(
|
||||
uiType = when (dto.uiType) {
|
||||
"linked_to_provider" -> MarketingBanner.UiType.LINKED_TO_PROVIDER
|
||||
else -> MarketingBanner.UiType.STANDALONE
|
||||
},
|
||||
text = dto.text,
|
||||
iconUrl = dto.icon,
|
||||
iconAlign = when (dto.iconAlign) {
|
||||
"left" -> MarketingBanner.IconAlign.LEFT
|
||||
"right" -> MarketingBanner.IconAlign.RIGHT
|
||||
else -> null
|
||||
},
|
||||
bgColor = dto.bgColor,
|
||||
deeplink = dto.deeplink,
|
||||
isDismissible = dto.isDismissible,
|
||||
)
|
||||
|
||||
private fun convertTarget(dto: CampaignTokenDto): MarketingCampaignTarget? {
|
||||
val coingeckoId = dto.id
|
||||
val networkId = dto.networkId
|
||||
return when {
|
||||
coingeckoId != null -> MarketingCampaignTarget.CoingeckoId(id = coingeckoId)
|
||||
// contractAddress may be null — that's a native coin of [networkId], not an invalid target.
|
||||
networkId != null -> MarketingCampaignTarget.NetworkContract(
|
||||
networkId = networkId,
|
||||
contractAddress = dto.contractAddress,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.marketing.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.data.marketing.DefaultMarketingRepository
|
||||
import com.tangem.data.marketing.converter.MarketingCampaignConverter
|
||||
import com.tangem.data.marketing.featuretoggle.DefaultMarketingFeatureToggles
|
||||
import com.tangem.data.marketing.store.DefaultMarketingCampaignsCacheStore
|
||||
import com.tangem.data.marketing.store.DefaultMarketingDismissStore
|
||||
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
|
||||
import com.tangem.data.marketing.store.MarketingDismissStore
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.datasource.utils.setTypes
|
||||
import com.tangem.domain.marketing.MarketingFeatureToggles
|
||||
import com.tangem.domain.marketing.MarketingRepository
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object MarketingDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMarketingCampaignsCacheStore(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): MarketingCampaignsCacheStore = DefaultMarketingCampaignsCacheStore(
|
||||
dataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = mapWithStringKeyTypes<MarketingCampaignsCacheEntry>(),
|
||||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "marketing_campaigns_cache") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMarketingDismissStore(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
appScope: AppCoroutineScope,
|
||||
): MarketingDismissStore = DefaultMarketingDismissStore(
|
||||
dataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = setTypes<Int>(),
|
||||
defaultValue = emptySet(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "marketing_dismissed_banner_ids") },
|
||||
scope = appScope,
|
||||
),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMarketingFeatureToggles(featureTogglesManager: FeatureTogglesManager): MarketingFeatureToggles =
|
||||
DefaultMarketingFeatureToggles(featureTogglesManager)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMarketingRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
cacheStore: MarketingCampaignsCacheStore,
|
||||
dismissStore: MarketingDismissStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): MarketingRepository = DefaultMarketingRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
cacheStore = cacheStore,
|
||||
dismissStore = dismissStore,
|
||||
converter = MarketingCampaignConverter(),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.data.marketing.featuretoggle
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.marketing.MarketingFeatureToggles
|
||||
|
||||
internal class DefaultMarketingFeatureToggles(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : MarketingFeatureToggles {
|
||||
|
||||
override val isMarketingBannersEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.data.marketing.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface MarketingCampaignsCacheStore {
|
||||
suspend fun get(type: String): MarketingCampaignsCacheEntry?
|
||||
suspend fun store(type: String, entry: MarketingCampaignsCacheEntry)
|
||||
}
|
||||
|
||||
internal class DefaultMarketingCampaignsCacheStore(
|
||||
private val dataStore: DataStore<Map<String, MarketingCampaignsCacheEntry>>,
|
||||
) : MarketingCampaignsCacheStore {
|
||||
|
||||
override suspend fun get(type: String): MarketingCampaignsCacheEntry? = dataStore.data.first()[type]
|
||||
|
||||
override suspend fun store(type: String, entry: MarketingCampaignsCacheEntry) {
|
||||
dataStore.updateData { it + (type to entry) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.marketing.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
interface MarketingDismissStore {
|
||||
suspend fun getDismissedIds(): Set<Int>
|
||||
suspend fun dismiss(id: Int)
|
||||
}
|
||||
|
||||
internal class DefaultMarketingDismissStore(
|
||||
private val dataStore: DataStore<Set<Int>>,
|
||||
) : MarketingDismissStore {
|
||||
|
||||
override suspend fun getDismissedIds(): Set<Int> = dataStore.data.first()
|
||||
|
||||
override suspend fun dismiss(id: Int) {
|
||||
dataStore.updateData { it + id }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
package com.tangem.data.marketing
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.marketing.converter.MarketingCampaignConverter
|
||||
import com.tangem.data.marketing.store.MarketingCampaignsCacheStore
|
||||
import com.tangem.data.marketing.store.MarketingDismissStore
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
|
||||
import com.tangem.datasource.api.common.response.ETAG_HEADER
|
||||
import com.tangem.datasource.api.marketing.models.BannerDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignDto
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.domain.marketing.models.MarketingScreen
|
||||
import com.tangem.domain.marketing.models.MarketingScreenType
|
||||
import com.tangem.utils.SupportedLanguages
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
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 DefaultMarketingRepositoryTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val cacheStore: MarketingCampaignsCacheStore = mockk(relaxed = true)
|
||||
private val dismissStore: MarketingDismissStore = mockk(relaxed = true)
|
||||
|
||||
private val language = SupportedLanguages.getCurrentSupportedLanguageCode()
|
||||
|
||||
// Recreated per test (not a val): DefaultMarketingRepository now holds mutable in-memory session-cache
|
||||
// state, which would otherwise leak between tests sharing this PER_CLASS instance.
|
||||
private lateinit var repository: DefaultMarketingRepository
|
||||
|
||||
@BeforeEach
|
||||
fun reset() {
|
||||
clearMocks(tangemTechApi, cacheStore, dismissStore)
|
||||
repository = DefaultMarketingRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
cacheStore = cacheStore,
|
||||
dismissStore = dismissStore,
|
||||
converter = MarketingCampaignConverter(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun response(id: Int) = MarketingCampaignsResponse(
|
||||
campaigns = listOf(CampaignDto(id = id, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone"))),
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun httpError(code: Code): ApiResponse<MarketingCampaignsResponse> = ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null),
|
||||
) as ApiResponse<MarketingCampaignsResponse>
|
||||
|
||||
@Test
|
||||
fun `GIVEN 200 for background type WHEN getCampaigns THEN stores etag and returns campaigns`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("token_details") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns
|
||||
ApiResponse.Success(data = response(id = 7), headers = mapOf(ETAG_HEADER to listOf("new-etag")))
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
coVerify(exactly = 1) {
|
||||
cacheStore.store("token_details", MarketingCampaignsCacheEntry(eTag = "new-etag", response = response(id = 7)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 304 for background type WHEN getCampaigns THEN returns cached campaigns`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("token_details") } returns
|
||||
MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 9))
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = "etag") } returns
|
||||
httpError(Code.NOT_MODIFIED)
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaigns(MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(9)
|
||||
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 5xx with cache WHEN getCampaigns THEN returns cached`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("staking") } returns
|
||||
MarketingCampaignsCacheEntry(eTag = "etag", response = response(id = 5))
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = "etag") } returns
|
||||
httpError(Code.SERVICE_UNAVAILABLE)
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 5xx without cache WHEN getCampaigns THEN returns empty`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("staking") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
|
||||
httpError(Code.INTERNAL_SERVER_ERROR)
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaigns(MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 5xx without cache WHEN getCampaigns twice THEN not session-cached and retried`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("staking") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) } returns
|
||||
httpError(Code.SERVICE_UNAVAILABLE)
|
||||
val screen = MarketingScreen.Staking(networkId = "ethereum", contractAddress = "0x")
|
||||
|
||||
// Act
|
||||
val first = repository.getCampaigns(screen)
|
||||
val second = repository.getCampaigns(screen)
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()).isEmpty()
|
||||
assertThat(second.getOrNull()).isEmpty()
|
||||
coVerify(exactly = 2) { tangemTechApi.getMarketingCampaigns(type = "staking", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap screen WHEN getCampaigns THEN sends pair params and does not touch cache`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
tangemTechApi.getMarketingCampaigns(
|
||||
type = "swap", language = language,
|
||||
fromNetwork = "ethereum", fromContractAddress = "0xFrom",
|
||||
toNetwork = "bitcoin", toContractAddress = "0xTo",
|
||||
)
|
||||
} returns ApiResponse.Success(data = response(id = 3))
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaigns(
|
||||
MarketingScreen.Swap(
|
||||
fromNetwork = "ethereum", fromContractAddress = "0xFrom",
|
||||
toNetwork = "bitcoin", toContractAddress = "0xTo",
|
||||
),
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
|
||||
coVerify(exactly = 0) { cacheStore.get(any()) }
|
||||
coVerify(exactly = 0) { cacheStore.store(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached in session WHEN getCampaigns twice THEN api called once`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("token_details") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } returns
|
||||
ApiResponse.Success(data = response(id = 7))
|
||||
|
||||
// Act
|
||||
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
|
||||
val first = repository.getCampaigns(screen)
|
||||
val second = repository.getCampaigns(screen)
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two concurrent getCampaigns for same type WHEN both in flight THEN api called once`() = runTest {
|
||||
// Arrange
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { cacheStore.get("token_details") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) } coAnswers {
|
||||
gate.await() // first caller suspends inside the lock, second blocks on the mutex
|
||||
ApiResponse.Success(data = response(id = 7))
|
||||
}
|
||||
val screen = MarketingScreen.TokenDetails(networkId = "ethereum", contractAddress = "0x")
|
||||
|
||||
// Act — launch both before either completes, then release the API
|
||||
val a = async { repository.getCampaigns(screen) }
|
||||
val b = async { repository.getCampaigns(screen) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
val first = a.await()
|
||||
val second = b.await()
|
||||
|
||||
// Assert
|
||||
assertThat(first.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
assertThat(second.getOrNull()!!.map { it.id }).containsExactly(7)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "token_details", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN prefetch WHEN getCampaigns THEN served from session cache without extra api call`() = runTest {
|
||||
// Arrange
|
||||
coEvery { cacheStore.get("markets_token") } returns null
|
||||
coEvery { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) } returns
|
||||
ApiResponse.Success(data = response(id = 3))
|
||||
|
||||
// Act
|
||||
repository.prefetchBackgroundCampaigns(MarketingScreenType.TOKEN_MARKETS)
|
||||
val result = repository.getCampaigns(MarketingScreen.TokenMarkets(coingeckoId = "id"))
|
||||
|
||||
// Assert
|
||||
assertThat(result.getOrNull()!!.map { it.id }).containsExactly(3)
|
||||
coVerify(exactly = 1) { tangemTechApi.getMarketingCampaigns(type = "markets_token", language = language, eTag = null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap WHEN getCampaigns twice THEN never session-cached (api called each time)`() = runTest {
|
||||
// Arrange
|
||||
val swap = MarketingScreen.Swap("eth", "0xF", "btc", "0xT")
|
||||
coEvery {
|
||||
tangemTechApi.getMarketingCampaigns(
|
||||
type = "swap", language = language,
|
||||
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
|
||||
)
|
||||
} returns ApiResponse.Success(data = response(id = 1))
|
||||
|
||||
// Act
|
||||
repository.getCampaigns(swap)
|
||||
repository.getCampaigns(swap)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 2) {
|
||||
tangemTechApi.getMarketingCampaigns(
|
||||
type = "swap", language = language,
|
||||
fromNetwork = "eth", fromContractAddress = "0xF", toNetwork = "btc", toContractAddress = "0xT",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dismiss WHEN dismissBanner THEN delegates to dismiss store`() = runTest {
|
||||
// Act
|
||||
repository.dismissBanner(42)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { dismissStore.dismiss(42) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package com.tangem.data.marketing.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.marketing.models.BannerDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignTokenDto
|
||||
import com.tangem.domain.marketing.models.MarketingBanner
|
||||
import com.tangem.domain.marketing.models.MarketingCampaignTarget
|
||||
import com.tangem.domain.marketing.models.MarketingScreenType
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class MarketingCampaignConverterTest {
|
||||
|
||||
private val converter = MarketingCampaignConverter()
|
||||
|
||||
private fun banner(uiType: String = "standalone", isDismissible: Boolean = true) = BannerDto(
|
||||
uiType = uiType,
|
||||
text = "Cashback",
|
||||
icon = "https://x/star.webp",
|
||||
iconAlign = "left",
|
||||
bgColor = "#FF0011",
|
||||
deeplink = "https://tangem.com",
|
||||
isDismissible = isDismissible,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap campaign WHEN convert THEN mapped with amounts and no targets`() {
|
||||
// Arrange
|
||||
val dto = CampaignDto(
|
||||
id = 12, type = "swap", priority = 1,
|
||||
minAmount = BigDecimal(50), maxAmount = BigDecimal(300),
|
||||
providerIds = listOf("provider1"), tokens = null,
|
||||
banner = banner(uiType = "linked_to_provider"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(dto)
|
||||
|
||||
// Assert
|
||||
assertThat(result.id).isEqualTo(12)
|
||||
assertThat(result.type).isEqualTo(MarketingScreenType.SWAP)
|
||||
assertThat(result.minAmount).isEqualTo(BigDecimal(50))
|
||||
assertThat(result.banner.uiType).isEqualTo(MarketingBanner.UiType.LINKED_TO_PROVIDER)
|
||||
assertThat(result.banner.iconAlign).isEqualTo(MarketingBanner.IconAlign.LEFT)
|
||||
assertThat(result.targets).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token_details campaign WHEN convert THEN network targets mapped`() {
|
||||
// Arrange
|
||||
val dto = CampaignDto(
|
||||
id = 1, type = "token_details", priority = 2, banner = banner(),
|
||||
tokens = listOf(CampaignTokenDto(networkId = "ethereum", contractAddress = "0xA0b8")),
|
||||
)
|
||||
|
||||
// Act
|
||||
val targets = converter.convert(dto).targets
|
||||
|
||||
// Assert
|
||||
assertThat(targets).containsExactly(
|
||||
MarketingCampaignTarget.NetworkContract(networkId = "ethereum", contractAddress = "0xA0b8"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN native coin token (null contract) WHEN convert THEN network target with null contract`() {
|
||||
// Arrange
|
||||
val dto = CampaignDto(
|
||||
id = 1, type = "yield", priority = 1, banner = banner(),
|
||||
tokens = listOf(CampaignTokenDto(networkId = "bitcoin", contractAddress = null)),
|
||||
)
|
||||
|
||||
// Act
|
||||
val targets = converter.convert(dto).targets
|
||||
|
||||
// Assert
|
||||
assertThat(targets).containsExactly(
|
||||
MarketingCampaignTarget.NetworkContract(networkId = "bitcoin", contractAddress = null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN markets campaign WHEN convert THEN coingecko targets mapped`() {
|
||||
// Arrange
|
||||
val dto = CampaignDto(
|
||||
id = 1, type = "markets_token", priority = 1, banner = banner(),
|
||||
tokens = listOf(CampaignTokenDto(id = "1696501400")),
|
||||
)
|
||||
|
||||
// Act
|
||||
val targets = converter.convert(dto).targets
|
||||
|
||||
// Assert
|
||||
assertThat(targets).containsExactly(MarketingCampaignTarget.CoingeckoId(id = "1696501400"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN linked_to_provider without providerIds WHEN convertListIgnoreErrors THEN dropped`() {
|
||||
// Arrange
|
||||
val invalid = CampaignDto(
|
||||
id = 1, type = "onramp", priority = 1, providerIds = emptyList(),
|
||||
banner = banner(uiType = "linked_to_provider"),
|
||||
)
|
||||
val valid = CampaignDto(id = 2, type = "onramp", priority = 2, banner = banner())
|
||||
|
||||
// Act
|
||||
val result = converter.convertListIgnoreErrors(listOf(invalid, valid))
|
||||
|
||||
// Assert
|
||||
assertThat(result.map { it.id }).containsExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown type WHEN convertListIgnoreErrors THEN dropped`() {
|
||||
// Arrange
|
||||
val dto = CampaignDto(id = 1, type = "carousel", priority = 1, banner = banner())
|
||||
|
||||
// Act
|
||||
val result = converter.convertListIgnoreErrors(listOf(dto))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.tangem.data.marketing.featuretoggle
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class DefaultMarketingFeatureTogglesTest {
|
||||
|
||||
private val featureTogglesManager: FeatureTogglesManager = mockk()
|
||||
private val featureToggles = DefaultMarketingFeatureToggles(featureTogglesManager)
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle enabled WHEN isMarketingBannersEnabled THEN true`() {
|
||||
// Arrange
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns true
|
||||
|
||||
// Assert
|
||||
assertThat(featureToggles.isMarketingBannersEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle disabled WHEN isMarketingBannersEnabled THEN false`() {
|
||||
// Arrange
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1522_MARKETING_BANNERS_ENABLED) } returns false
|
||||
|
||||
// Assert
|
||||
assertThat(featureToggles.isMarketingBannersEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.data.marketing.store
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.marketing.models.BannerDto
|
||||
import com.tangem.datasource.api.marketing.models.CampaignDto
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsCacheEntry
|
||||
import com.tangem.datasource.api.marketing.models.MarketingCampaignsResponse
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class MarketingStoresTest {
|
||||
|
||||
private val cacheStore = DefaultMarketingCampaignsCacheStore(
|
||||
dataStore = MockStateDataStore<Map<String, MarketingCampaignsCacheEntry>>(default = emptyMap()),
|
||||
)
|
||||
private val dismissStore = DefaultMarketingDismissStore(
|
||||
dataStore = MockStateDataStore<Set<Int>>(default = emptySet()),
|
||||
)
|
||||
|
||||
private fun entry(eTag: String?) = MarketingCampaignsCacheEntry(
|
||||
eTag = eTag,
|
||||
response = MarketingCampaignsResponse(
|
||||
campaigns = listOf(
|
||||
CampaignDto(id = 1, type = "token_details", priority = 1, banner = BannerDto(uiType = "standalone")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN no cache WHEN get THEN null`() = runTest {
|
||||
assertThat(cacheStore.get("token_details")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored entry WHEN get same type THEN returns it`() = runTest {
|
||||
// Arrange
|
||||
cacheStore.store("token_details", entry(eTag = "abc"))
|
||||
|
||||
// Act
|
||||
val result = cacheStore.get("token_details")
|
||||
|
||||
// Assert
|
||||
assertThat(result?.eTag).isEqualTo("abc")
|
||||
assertThat(result?.response?.campaigns).hasSize(1)
|
||||
assertThat(cacheStore.get("staking")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no dismissed WHEN getDismissedIds THEN empty`() = runTest {
|
||||
assertThat(dismissStore.getDismissedIds()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dismissed ids WHEN dismiss again THEN accumulates without duplicates`() = runTest {
|
||||
// Act
|
||||
dismissStore.dismiss(12)
|
||||
dismissStore.dismiss(12)
|
||||
dismissStore.dismiss(34)
|
||||
|
||||
// Assert
|
||||
assertThat(dismissStore.getDismissedIds()).containsExactly(12, 34)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -14,40 +13,49 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Libs */
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.analytics.models)
|
||||
implementation(projects.core.configToggles)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.markets)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
|
||||
implementation(projects.data.common)
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(tangemDeps.blockchain)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(tangemDeps.blockchain)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.analytics.models)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.markets)
|
||||
implementation(projects.domain.models)
|
||||
runtimeOnly(projects.domain.tokens)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.markets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Tests dependencies
|
||||
|
|
|
|||
|
|
@ -9,27 +9,18 @@ android {
|
|||
namespace = "com.tangem.data.networks"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.dynamicAddresses)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.walletManager)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Project - Libs (SDK)
|
||||
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
|
||||
// endregion
|
||||
|
||||
|
|
@ -38,15 +29,32 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// region Project - Core
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
api(projects.data.dynamicAddresses)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.networks)
|
||||
api(projects.domain.walletManager)
|
||||
runtimeOnly(projects.domain.card)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(tangemDeps.blockchain)
|
||||
testImplementation(tangemDeps.card.core)
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.domain.card)
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor(
|
|||
commonNetworkStatusFetcher.fetch(
|
||||
userWalletId = params.userWalletId,
|
||||
network = network,
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet(),
|
||||
networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens,
|
||||
xpub = xpubByNetwork[network],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor(
|
|||
params = MultiNetworkStatusFetcher.Params(
|
||||
userWalletId = params.userWalletId,
|
||||
networks = setOf(params.network),
|
||||
extraTokens = params.extraTokens,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,21 +9,13 @@ android {
|
|||
namespace = "com.tangem.data.news"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.news)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -31,16 +23,19 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.pagination)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.news)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -4,55 +4,61 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.nft"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Project - Data */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.data.common)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** Project - Domain */
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region Libs - Tangem
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.nft)
|
||||
implementation(projects.domain.nft.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Project - Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
/** Feature Api modules */
|
||||
implementation(projects.features.nft.api)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.androidx.paging.runtime)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
|
||||
/** Libs - Tangem */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
testImplementation(projects.test.core)
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -10,13 +10,19 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region sdk
|
||||
implementation(tangemDeps.blockchain)
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region AndroidX libraries
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -24,29 +30,23 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Arrow
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
api(projects.domain.notifications)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.notifications.models)
|
||||
implementation(projects.domain.notifications)
|
||||
// endregion
|
||||
|
||||
// region tests
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.moshi)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -11,8 +11,13 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
// region AndroidX libraries
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -21,11 +26,12 @@ dependencies {
|
|||
// endregion
|
||||
|
||||
// region Core modules
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
implementation(projects.domain.onboarding)
|
||||
api(projects.domain.onboarding)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -14,52 +14,61 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.core.analytics)
|
||||
|
||||
/** Common modules */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain modules */
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.onramp)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.appTheme.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.txhistory)
|
||||
|
||||
// region DI
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
implementation(deps.kotlin.serialization)
|
||||
// endregion
|
||||
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.blockchain) {
|
||||
// region Tangem SDK
|
||||
api(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
// endregion
|
||||
|
||||
// region Data modules
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain modules
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.onramp)
|
||||
api(projects.domain.txhistory)
|
||||
api(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
runtimeOnly(projects.domain.account)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
api(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.net.Uri
|
|||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.blockchain.extensions.toBigDecimalOrDefault
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.onramp.converters.CountryConverter
|
||||
import com.tangem.data.onramp.converters.CurrencyConverter
|
||||
import com.tangem.data.onramp.converters.PaymentMethodConverter
|
||||
|
|
@ -76,6 +77,7 @@ internal class DefaultOnrampRepository(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val dataSignatureVerifier: DataSignatureVerifier,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val expressHistoryRepository: ExpressHistoryRepository,
|
||||
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
|
||||
moshi: Moshi,
|
||||
) : OnrampRepository {
|
||||
|
|
@ -115,7 +117,7 @@ internal class DefaultOnrampRepository(
|
|||
override suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry> = withContext(dispatchers.io) {
|
||||
if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList()
|
||||
|
||||
val result = onrampApi.getCountries(
|
||||
val response = onrampApi.getCountries(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
|
|
@ -123,8 +125,12 @@ internal class DefaultOnrampRepository(
|
|||
),
|
||||
)
|
||||
.getOrThrow()
|
||||
.map(countryConverter::convert)
|
||||
|
||||
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
|
||||
expressHistoryDao.upsertCountries(response.map { it.toEntity() })
|
||||
}
|
||||
|
||||
val result = response.map(countryConverter::convert)
|
||||
countriesStore.store(COUNTRIES_KEY, result)
|
||||
|
||||
result
|
||||
|
|
@ -168,7 +174,7 @@ internal class DefaultOnrampRepository(
|
|||
.getOrThrow()
|
||||
|
||||
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
|
||||
expressHistoryDao.upsertOnramps(listOf(response.toEntity(ownerAddress = response.payoutAddress)))
|
||||
expressHistoryRepository.storeOnramps(items = listOf(response))
|
||||
}
|
||||
|
||||
statusConverter.convert(response)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||
import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
||||
import com.tangem.data.onramp.DefaultHotCryptoRepository
|
||||
import com.tangem.data.onramp.DefaultOnrampErrorResolver
|
||||
import com.tangem.data.onramp.DefaultOnrampRepository
|
||||
|
|
@ -59,6 +60,7 @@ internal object OnrampDataModule {
|
|||
dataSignatureVerifier: DataSignatureVerifier,
|
||||
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
|
||||
expressHistoryDao: ExpressHistoryDao,
|
||||
expressHistoryRepository: ExpressHistoryRepository,
|
||||
txHistoryFeatureToggles: TxHistoryFeatureToggles,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
): OnrampRepository {
|
||||
|
|
@ -76,6 +78,7 @@ internal object OnrampDataModule {
|
|||
walletManagersFacade = walletManagersFacade,
|
||||
dataSignatureVerifier = dataSignatureVerifier,
|
||||
expressHistoryDao = expressHistoryDao,
|
||||
expressHistoryRepository = expressHistoryRepository,
|
||||
txHistoryFeatureToggles = txHistoryFeatureToggles,
|
||||
moshi = moshi,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,48 +1,9 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.payment"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Project - Data */
|
||||
implementation(projects.core.error)
|
||||
implementation(projects.core.error.ext)
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.wallets)
|
||||
|
||||
/** Project - Domain */
|
||||
implementation(projects.domain.payment)
|
||||
implementation(projects.domain.payment.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.common)
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Libs - Tangem */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
implementation(tangemDeps.card.android)
|
||||
implementation(tangemDeps.hot.core)
|
||||
implementation(projects.libs.tangemSdkApi)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
}
|
||||
46
data/promo/build.gradle.kts
Normal file
46
data/promo/build.gradle.kts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.promo"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.datetime)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.promo)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.promo.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.data.promo
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.promo.converter.PromoCampaignConverter
|
||||
import com.tangem.data.promo.store.PromoEnrollmentStore
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
|
||||
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.promotion.PromotionsSupplier
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.promo.models.EnrollResult
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultPromoRepository(
|
||||
private val promotionsSupplier: PromotionsSupplier,
|
||||
private val tangemApi: TangemTechApi,
|
||||
private val enrollmentStore: PromoEnrollmentStore,
|
||||
private val moshi: Moshi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : PromoRepository {
|
||||
|
||||
override suspend fun getCampaignState(
|
||||
campaign: PromoCampaignId,
|
||||
userWalletId: UserWalletId,
|
||||
forceRefresh: Boolean,
|
||||
): PromoCampaignState = withContext(dispatchers.io) {
|
||||
enrollmentStore.getSyncOrNull(campaign)?.let {
|
||||
return@withContext PromoCampaignState.Enrolled(campaign, it)
|
||||
}
|
||||
val all = promotionsSupplier.getPromotions(userWalletId, forceRefresh)
|
||||
.promotions.firstOrNull { it.name == campaign.slug }?.all
|
||||
when {
|
||||
all == null -> PromoCampaignState.NotActive(campaign)
|
||||
all.status == ACTIVE_STATUS -> PromoCampaignConverter.toAvailable(campaign, all)
|
||||
else -> PromoCampaignState.NotActive(campaign)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun enroll(
|
||||
campaign: PromoCampaignId,
|
||||
tokenReward: TokenReward,
|
||||
walletIds: List<UserWalletId>,
|
||||
): EnrollResult = withContext(dispatchers.io) {
|
||||
val body = CreatePromotionRegistrationBody(
|
||||
campaignId = campaign.slug,
|
||||
walletIds = walletIds.map { it.stringValue },
|
||||
tokenReward = tokenReward.toDto(),
|
||||
)
|
||||
when (val response = tangemApi.createPromotionRegistration(body)) {
|
||||
is ApiResponse.Success -> {
|
||||
val saved = response.data.data.tokenReward.toDomain()
|
||||
enrollmentStore.store(campaign, saved)
|
||||
EnrollResult.Success(saved)
|
||||
}
|
||||
is ApiResponse.Error -> {
|
||||
val cause = response.cause
|
||||
val conflict = (cause as? ApiResponseError.HttpException)
|
||||
?.takeIf { it.code == ApiResponseError.HttpException.Code.CONFLICT }
|
||||
if (conflict != null) {
|
||||
val existing = parseConflict(conflict.errorBody)?.data?.tokenReward?.toDomain() ?: tokenReward
|
||||
enrollmentStore.store(campaign, existing)
|
||||
EnrollResult.AlreadyEnrolled(existing)
|
||||
} else {
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseConflict(body: String?): PromotionRegistrationResponse? {
|
||||
if (body.isNullOrBlank()) return null
|
||||
return runCatching {
|
||||
moshi.adapter(PromotionRegistrationResponse::class.java).fromJson(body)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun TokenReward.toDto() = CreatePromotionRegistrationBody.TokenRewardDto(
|
||||
tokenAddress = tokenAddress,
|
||||
networkId = networkId,
|
||||
)
|
||||
|
||||
private fun CreatePromotionRegistrationBody.TokenRewardDto.toDomain() = TokenReward(
|
||||
tokenAddress = tokenAddress,
|
||||
networkId = networkId,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTIVE_STATUS = "active"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.tangem.data.promo.converter
|
||||
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import com.tangem.domain.promo.models.PromoTimeline
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
internal object PromoCampaignConverter {
|
||||
|
||||
fun toAvailable(campaign: PromoCampaignId, all: All): PromoCampaignState.Available {
|
||||
return PromoCampaignState.Available(
|
||||
campaign = campaign,
|
||||
payoutTokens = all.tokens.orEmpty().map { token ->
|
||||
PromoPayoutToken(
|
||||
tokenId = token.tokenId,
|
||||
tokenAddress = token.tokenAddress,
|
||||
tokenSymbol = token.tokenSymbol,
|
||||
tokenName = token.tokenName,
|
||||
networkId = token.networkId,
|
||||
decimals = token.decimals,
|
||||
)
|
||||
},
|
||||
timeline = PromoTimeline(
|
||||
start = Instant.parse(all.timeline.start),
|
||||
end = Instant.parse(all.timeline.end),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.data.promo.di
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.promo.DefaultPromoRepository
|
||||
import com.tangem.data.promo.store.DefaultPromoEnrollmentStore
|
||||
import com.tangem.data.promo.store.PromoEnrollmentStore
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.promotion.PromotionsSupplier
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PromoDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePromoEnrollmentStore(appPreferencesStore: AppPreferencesStore): PromoEnrollmentStore {
|
||||
return DefaultPromoEnrollmentStore(appPreferencesStore)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePromoRepository(
|
||||
promotionsSupplier: PromotionsSupplier,
|
||||
tangemApi: TangemTechApi,
|
||||
enrollmentStore: PromoEnrollmentStore,
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): PromoRepository {
|
||||
return DefaultPromoRepository(
|
||||
promotionsSupplier = promotionsSupplier,
|
||||
tangemApi = tangemApi,
|
||||
enrollmentStore = enrollmentStore,
|
||||
moshi = moshi,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.data.promo.store
|
||||
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
|
||||
internal class DefaultPromoEnrollmentStore(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : PromoEnrollmentStore {
|
||||
|
||||
override suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward? {
|
||||
return appPreferencesStore
|
||||
.getObjectMapSync<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)[campaign.slug]
|
||||
}
|
||||
|
||||
override suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward) {
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val current = mutablePreferences.getObjectMap<TokenReward>(PreferencesKeys.PROMO_ENROLLMENTS_KEY)
|
||||
mutablePreferences.setObjectMap(
|
||||
key = PreferencesKeys.PROMO_ENROLLMENTS_KEY,
|
||||
value = current + (campaign.slug to tokenReward),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.data.promo.store
|
||||
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
|
||||
interface PromoEnrollmentStore {
|
||||
|
||||
suspend fun getSyncOrNull(campaign: PromoCampaignId): TokenReward?
|
||||
|
||||
suspend fun store(campaign: PromoCampaignId, tokenReward: TokenReward)
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package com.tangem.data.promo
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.tangem.data.promo.store.PromoEnrollmentStore
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.promotion.models.CreatePromotionRegistrationBody
|
||||
import com.tangem.datasource.api.promotion.models.PromotionRegistrationResponse
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.promotion.PromotionsSupplier
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.EnrollResult
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoCampaignState
|
||||
import com.tangem.domain.promo.models.TokenReward
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
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 DefaultPromoRepositoryTest {
|
||||
|
||||
private val promotionsSupplier: PromotionsSupplier = mockk()
|
||||
private val tangemApi: TangemTechApi = mockk()
|
||||
private val enrollmentStore: PromoEnrollmentStore = mockk(relaxed = true)
|
||||
private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
|
||||
private val repository = DefaultPromoRepository(
|
||||
promotionsSupplier = promotionsSupplier,
|
||||
tangemApi = tangemApi,
|
||||
enrollmentStore = enrollmentStore,
|
||||
moshi = moshi,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val campaign = PromoCampaignId.WhaleSwapCashback
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
private val tokenReward = TokenReward("0xToken", "ethereum")
|
||||
|
||||
private fun activeDto() = PromotionDto(
|
||||
name = campaign.slug,
|
||||
all = All(
|
||||
timeline = Timeline("2026-06-23T00:00:00.000Z", "2026-08-31T20:59:59.000Z"),
|
||||
tokens = listOf(
|
||||
PromoToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xToken",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
link = "",
|
||||
),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() = clearMocks(promotionsSupplier, tangemApi, enrollmentStore)
|
||||
|
||||
@Test
|
||||
fun `GIVEN locally enrolled WHEN getCampaignState THEN Enrolled without api`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns tokenReward
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaignState(campaign, userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(PromoCampaignState.Enrolled(campaign, tokenReward))
|
||||
coVerify(exactly = 0) { promotionsSupplier.getPromotions(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active campaign present and not enrolled WHEN getCampaignState THEN Available`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
|
||||
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
|
||||
PromotionsResponse(promotions = listOf(activeDto()))
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaignState(campaign, userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(PromoCampaignState.Available::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN campaign absent WHEN getCampaignState THEN NotActive`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
|
||||
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
|
||||
PromotionsResponse(promotions = emptyList())
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaignState(campaign, userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN campaign present but finished WHEN getCampaignState THEN NotActive`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enrollmentStore.getSyncOrNull(campaign) } returns null
|
||||
val finished = activeDto().copy(all = activeDto().all!!.copy(status = "finished"))
|
||||
coEvery { promotionsSupplier.getPromotions(userWalletId, any()) } returns
|
||||
PromotionsResponse(promotions = listOf(finished))
|
||||
|
||||
// Act
|
||||
val result = repository.getCampaignState(campaign, userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(PromoCampaignState.NotActive(campaign))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api returns 201 with canonical token WHEN enroll THEN Success and persists backend token`() = runTest {
|
||||
// Arrange
|
||||
val data = PromotionRegistrationResponse.RegistrationData(
|
||||
campaignId = campaign.slug,
|
||||
registeredAt = "2026-07-06T09:27:13.363Z",
|
||||
tokenReward = CreatePromotionRegistrationBody.TokenRewardDto("0xCanonical", "ethereum"),
|
||||
)
|
||||
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Success(
|
||||
PromotionRegistrationResponse(status = "saved", message = null, data = data),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
|
||||
|
||||
// Assert
|
||||
val backendToken = TokenReward("0xCanonical", "ethereum")
|
||||
assertThat(result).isEqualTo(EnrollResult.Success(backendToken))
|
||||
coVerify(exactly = 1) { enrollmentStore.store(campaign, backendToken) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api returns 409 WHEN enroll THEN AlreadyEnrolled with existing token`() = runTest {
|
||||
// Arrange
|
||||
val existing = """
|
||||
{"status":"already_exists","message":"exists","data":{"campaignId":"${campaign.slug}",
|
||||
"registeredAt":"2026-07-01T10:00:00.000Z","tokenReward":{"tokenAddress":"0xOther",
|
||||
"networkId":"base","userAddress":"0xExisting"}}}
|
||||
""".trimIndent()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
|
||||
ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.CONFLICT,
|
||||
message = "conflict",
|
||||
errorBody = existing,
|
||||
),
|
||||
) as ApiResponse<PromotionRegistrationResponse>
|
||||
|
||||
// Act
|
||||
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
|
||||
|
||||
// Assert
|
||||
val expectedToken = TokenReward("0xOther", "base")
|
||||
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(expectedToken))
|
||||
coVerify(exactly = 1) { enrollmentStore.store(campaign, expectedToken) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN 409 with null errorBody WHEN enroll THEN AlreadyEnrolled with submitted token`() = runTest {
|
||||
// Arrange
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
|
||||
ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.CONFLICT,
|
||||
message = "conflict",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<PromotionRegistrationResponse>
|
||||
|
||||
// Act
|
||||
val result = repository.enroll(campaign, tokenReward, listOf(userWalletId))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(EnrollResult.AlreadyEnrolled(tokenReward))
|
||||
coVerify(exactly = 1) { enrollmentStore.store(campaign, tokenReward) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api returns 500 WHEN enroll THEN throws`() = runTest {
|
||||
// Arrange
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { tangemApi.createPromotionRegistration(any()) } returns ApiResponse.Error(
|
||||
ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
|
||||
message = "server",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<PromotionRegistrationResponse>
|
||||
|
||||
// Act
|
||||
val error = runCatching { repository.enroll(campaign, tokenReward, listOf(userWalletId)) }.exceptionOrNull()
|
||||
|
||||
// Assert
|
||||
assertThat(error).isNotNull()
|
||||
coVerify(exactly = 0) { enrollmentStore.store(any(), any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.tangem.data.promo.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.All
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.PromoToken
|
||||
import com.tangem.datasource.api.promotion.models.PromotionsResponse.PromotionDto.Timeline
|
||||
import com.tangem.domain.promo.models.PromoCampaignId
|
||||
import com.tangem.domain.promo.models.PromoPayoutToken
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class PromoCampaignConverterTest {
|
||||
|
||||
private val campaign = PromoCampaignId.WhaleSwapCashback
|
||||
|
||||
@Test
|
||||
fun `GIVEN dto with tokens WHEN toAvailable THEN maps tokens and timeline`() {
|
||||
// Arrange
|
||||
val all = All(
|
||||
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
|
||||
tokens = listOf(
|
||||
PromoToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xdac1",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
),
|
||||
status = "active",
|
||||
link = "",
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = PromoCampaignConverter.toAvailable(campaign, all)
|
||||
|
||||
// Assert
|
||||
assertThat(result.campaign).isEqualTo(campaign)
|
||||
assertThat(result.payoutTokens).containsExactly(
|
||||
PromoPayoutToken(
|
||||
tokenId = "tether",
|
||||
tokenAddress = "0xdac1",
|
||||
tokenSymbol = "USDT",
|
||||
tokenName = "Tether USD",
|
||||
networkId = "ethereum",
|
||||
decimals = 6,
|
||||
),
|
||||
)
|
||||
assertThat(result.timeline.start).isEqualTo(Instant.parse("2026-06-23T00:00:00.000Z"))
|
||||
assertThat(result.timeline.end).isEqualTo(Instant.parse("2026-08-31T20:59:59.000Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dto with null tokens WHEN toAvailable THEN empty payout list`() {
|
||||
// Arrange
|
||||
val all = All(
|
||||
timeline = Timeline(start = "2026-06-23T00:00:00.000Z", end = "2026-08-31T20:59:59.000Z"),
|
||||
tokens = null,
|
||||
status = "active",
|
||||
link = null,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = PromoCampaignConverter.toAvailable(campaign, all)
|
||||
|
||||
// Assert
|
||||
assertThat(result.payoutTokens).isEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -11,29 +11,36 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
/** Domain */
|
||||
implementation(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region Other libraries
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
// region Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.pushNotificationPreferences)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.turbine)
|
||||
testImplementation(deps.moshi)
|
||||
testImplementation(deps.moshi.kotlin)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,54 +1,64 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.pushnotificationpreferences.converters.PushNotificationPreferencesConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
|
||||
*
|
||||
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
|
||||
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
|
||||
*
|
||||
* Defaults for existing users (until BE migration runs): TX read from
|
||||
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
|
||||
* isVisible = true for all three.
|
||||
* Preferences are cached in-memory (non-persistent); writes are full-replace PUTs and the server echo
|
||||
* is cached as the source of truth.
|
||||
*/
|
||||
internal class DefaultWalletPushNotificationPreferencesRepository(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
@Suppress("unused") private val tangemTechApi: TangemTechApi,
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : WalletPushNotificationPreferencesRepository {
|
||||
|
||||
override suspend fun preload(userWalletId: UserWalletId) {
|
||||
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
|
||||
val preferences = withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
|
||||
// PushNotificationPreferencesConverter.convert(response)
|
||||
loadDefaults(userWalletId)
|
||||
private val walletMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
override suspend fun isFirstActivationDone(userWalletId: UserWalletId): Boolean =
|
||||
appPreferencesStore.getSyncOrNull(PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY)
|
||||
?.contains(userWalletId.stringValue) == true
|
||||
|
||||
override suspend fun markFirstActivationDone(userWalletId: UserWalletId) {
|
||||
appPreferencesStore.editData { preferences ->
|
||||
val key = PreferencesKeys.PUSH_NOTIFICATION_FIRST_ACTIVATION_DONE_WALLET_IDS_KEY
|
||||
preferences[key] = preferences.getOrDefault(key, emptySet()) + userWalletId.stringValue
|
||||
}
|
||||
cache.update(default = emptyMap()) { current ->
|
||||
if (current.containsKey(userWalletId.stringValue)) {
|
||||
current
|
||||
} else {
|
||||
current + (userWalletId.stringValue to preferences)
|
||||
}
|
||||
|
||||
override suspend fun preload(userWalletId: UserWalletId) {
|
||||
if (isCached(userWalletId)) return
|
||||
mutexFor(userWalletId).withLock {
|
||||
if (isCached(userWalletId)) return
|
||||
val preferences = fetch(userWalletId)
|
||||
cache.update(default = emptyMap()) { current ->
|
||||
if (current.containsKey(userWalletId.stringValue)) {
|
||||
current
|
||||
} else {
|
||||
current + (userWalletId.stringValue to preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,9 +74,11 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
|
|||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit> = Either.catch {
|
||||
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
|
||||
val updated = current.withCategory(category, isEnabled)
|
||||
putAndCommit(userWalletId, updated)
|
||||
mutexFor(userWalletId).withLock {
|
||||
val current = currentOrFetch(userWalletId)
|
||||
val updated = current.withCategory(category, isEnabled)
|
||||
putAndCommit(userWalletId, updated)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setAllPreferences(
|
||||
|
|
@ -75,39 +87,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
|
|||
offersUpdates: Boolean,
|
||||
priceAlerts: Boolean,
|
||||
): Either<Throwable, Unit> = Either.catch {
|
||||
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
|
||||
val updated = current.copy(
|
||||
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
|
||||
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
|
||||
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
|
||||
)
|
||||
putAndCommit(userWalletId, updated)
|
||||
mutexFor(userWalletId).withLock {
|
||||
val current = currentOrFetch(userWalletId)
|
||||
val updated = current.copy(
|
||||
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
|
||||
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
|
||||
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
|
||||
)
|
||||
putAndCommit(userWalletId, updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults.
|
||||
private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
|
||||
cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId)
|
||||
|
||||
private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences =
|
||||
withContext(dispatchers.io) {
|
||||
val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
|
||||
PushNotificationPreferencesConverter.convert(response)
|
||||
}
|
||||
|
||||
private suspend fun isCached(userWalletId: UserWalletId): Boolean =
|
||||
cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true
|
||||
|
||||
private fun mutexFor(userWalletId: UserWalletId): Mutex =
|
||||
walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() }
|
||||
|
||||
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
|
||||
withContext(dispatchers.io) {
|
||||
// TODO: uncomment when api is ready
|
||||
// tangemTechApi.updatePushNotificationPreferences(
|
||||
// walletId = userWalletId.stringValue,
|
||||
// body = PushNotificationPreferencesBody(
|
||||
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
|
||||
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
|
||||
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
|
||||
// ),
|
||||
// ).getOrThrow()
|
||||
val applied = withContext(dispatchers.io) {
|
||||
val response = tangemTechApi.updatePushNotificationPreferences(
|
||||
walletId = userWalletId.stringValue,
|
||||
body = PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = updated.transactionAlerts.isEnabled,
|
||||
areOfferUpdatesEnabled = updated.offersUpdates.isEnabled,
|
||||
arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
|
||||
),
|
||||
).getOrThrow()
|
||||
PushNotificationPreferencesConverter.convert(response)
|
||||
}
|
||||
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
|
||||
}
|
||||
|
||||
// TODO remove when api is ready, use api methods to load real settings
|
||||
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
|
||||
val areTransactionAlertsEnabled = appPreferencesStore
|
||||
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
|
||||
false
|
||||
return WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
)
|
||||
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.tangem.data.pushnotificationpreferences.converters
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
|
|
@ -11,11 +10,8 @@ internal object PushNotificationPreferencesConverter :
|
|||
|
||||
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
|
||||
WalletPushNotificationPreferences(
|
||||
transactionAlerts = value.transactionAlerts.toDomain(),
|
||||
offersUpdates = value.offersUpdates.toDomain(),
|
||||
priceAlerts = value.priceAlerts.toDomain(),
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = value.areTransactionEventsEnabled),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = value.areOfferUpdatesEnabled),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = value.arePriceAlertsEnabled),
|
||||
)
|
||||
|
||||
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
|
||||
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
|
||||
}
|
||||
|
|
@ -19,13 +19,13 @@ internal object PushNotificationPreferencesModule {
|
|||
@Singleton
|
||||
@Provides
|
||||
fun providesWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,141 +1,254 @@
|
|||
package com.tangem.data.pushnotificationpreferences
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody
|
||||
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.test.core.datastore.MockStateDataStore
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultWalletPushNotificationPreferencesRepositoryTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val preferencesDataStore: DataStore<Preferences> = mockk()
|
||||
private val appPreferencesStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
preferencesDataStore = preferencesDataStore,
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
|
||||
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
|
||||
|
||||
// Real in-memory store so the persisted first-activation flag (a Set<String> merge) is genuinely exercised.
|
||||
private val appPreferencesStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
preferencesDataStore = MockStateDataStore(default = emptyPreferences()),
|
||||
)
|
||||
|
||||
private val repository = DefaultWalletPushNotificationPreferencesRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
cache = RuntimeSharedStore(),
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN server returns prefs WHEN preload THEN cache holds converted server state`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Assert
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = false))
|
||||
}
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN already preloaded WHEN preload called again THEN no second GET`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache miss WHEN updatePreference THEN fetches baseline AND sends full-replace PUT AND caches echo`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
stubPut(userWalletId, transaction = true, offers = true, price = true)
|
||||
|
||||
// Act
|
||||
val result = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
isEnabled = true,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(Either.Right::class.java)
|
||||
// The full-replace body changes only the tapped category on top of the server baseline.
|
||||
coVerify(exactly = 1) {
|
||||
tangemTechApi.updatePushNotificationPreferences(
|
||||
userWalletId.stringValue,
|
||||
PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = true,
|
||||
areOfferUpdatesEnabled = true,
|
||||
arePriceAlertsEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preloaded state WHEN updatePreference THEN only the tapped category changes in the PUT body`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
stubPut(userWalletId, transaction = true, offers = false, price = false)
|
||||
|
||||
// Act
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) {
|
||||
tangemTechApi.updatePushNotificationPreferences(
|
||||
userWalletId.stringValue,
|
||||
PushNotificationPreferencesBody(
|
||||
areTransactionEventsEnabled = true,
|
||||
areOfferUpdatesEnabled = false,
|
||||
arePriceAlertsEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN write fails WHEN updatePreference THEN returns Left`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
repository.preload(userWalletId)
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(any(), any()) } throws IllegalStateException("boom")
|
||||
|
||||
// Act
|
||||
val result = repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(Either.Left::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN different wallets WHEN observed THEN each keeps its own server state`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = false, offers = false, price = false)
|
||||
stubGet(otherWalletId, transaction = true, offers = true, price = true)
|
||||
|
||||
// Assert
|
||||
repository.observePreferences(userWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = false, offers = false, price = false))
|
||||
}
|
||||
repository.observePreferences(otherWalletId).test {
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN concurrent collectors WHEN preload races THEN a single GET is issued`() = runTest {
|
||||
// Arrange
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } coAnswers {
|
||||
gate.await()
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(true, true, false))
|
||||
}
|
||||
|
||||
// Act
|
||||
launch { repository.preload(userWalletId) }
|
||||
runCurrent()
|
||||
launch { repository.preload(userWalletId) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN concurrent writes WHEN updatePreference races THEN serialized so no update is lost`() = runTest {
|
||||
// Arrange
|
||||
stubGet(userWalletId, transaction = true, offers = true, price = false)
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } coAnswers {
|
||||
val body = arg<PushNotificationPreferencesBody>(1)
|
||||
gate.await()
|
||||
ApiResponse.Success(
|
||||
PushNotificationPreferencesResponse(
|
||||
body.areTransactionEventsEnabled,
|
||||
body.areOfferUpdatesEnabled,
|
||||
body.arePriceAlertsEnabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
repository.preload(userWalletId)
|
||||
|
||||
// Act
|
||||
launch { repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) }
|
||||
runCurrent()
|
||||
launch { repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) }
|
||||
runCurrent()
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 2) { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) }
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = false, price = true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
|
||||
val result = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = PushNotificationCategory.PriceAlerts,
|
||||
isEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(result).isInstanceOf(Either.Right::class.java)
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.transactionAlerts.isEnabled).isTrue()
|
||||
}
|
||||
fun `GIVEN wallet never activated WHEN isFirstActivationDone THEN false`() = runTest {
|
||||
assertThat(repository.isFirstActivationDone(userWalletId)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN wallet marked WHEN isFirstActivationDone THEN true and persisted`() = runTest {
|
||||
repository.markFirstActivationDone(userWalletId)
|
||||
|
||||
repository.preload(userWalletId)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.transactionAlerts.isEnabled).isFalse()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
fun `GIVEN one wallet marked WHEN another wallet marked THEN both stay done`() = runTest {
|
||||
// Guards the additive-merge backbone: a regression to a single-id overwrite would drop the first wallet.
|
||||
repository.markFirstActivationDone(userWalletId)
|
||||
repository.markFirstActivationDone(otherWalletId)
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
|
||||
}
|
||||
}
|
||||
assertThat(repository.isFirstActivationDone(userWalletId)).isTrue()
|
||||
assertThat(repository.isFirstActivationDone(otherWalletId)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
|
||||
runTest {
|
||||
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
|
||||
private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
|
||||
coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
|
||||
}
|
||||
|
||||
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
|
||||
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
|
||||
private fun stubPut(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) {
|
||||
coEvery { tangemTechApi.updatePushNotificationPreferences(eq(id.stringValue), any()) } returns
|
||||
ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price))
|
||||
}
|
||||
|
||||
repository.observePreferences(userWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isFalse()
|
||||
assertThat(item.priceAlerts.isEnabled).isFalse()
|
||||
}
|
||||
repository.observePreferences(otherWalletId).test {
|
||||
val item = awaitItem()
|
||||
assertThat(item.offersUpdates.isEnabled).isTrue()
|
||||
assertThat(item.priceAlerts.isEnabled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
|
||||
private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences(
|
||||
transactionAlerts = PushNotificationPreference(isEnabled = transaction),
|
||||
offersUpdates = PushNotificationPreference(isEnabled = offers),
|
||||
priceAlerts = PushNotificationPreference(isEnabled = price),
|
||||
)
|
||||
}
|
||||
|
|
@ -12,25 +12,37 @@ android {
|
|||
|
||||
dependencies {
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** SdK */
|
||||
// region SDK
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
/** DI */
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
// region Core modules
|
||||
implementation(projects.core.ui)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.qrScanning)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -9,22 +9,20 @@ android {
|
|||
namespace = "com.tangem.data.quotes"
|
||||
}
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// region Other libraries
|
||||
api(deps.moshi)
|
||||
implementation(deps.androidx.annotation)
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.arrow.core)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.quotes)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -32,13 +30,23 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// region Project - Core
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Data
|
||||
api(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.quotes)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
|||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
|
||||
import com.tangem.domain.quotes.IsHighNetworkFeeUseCase
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
|
|
@ -79,4 +80,12 @@ internal object QuotesDataModule {
|
|||
fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase {
|
||||
return GetCurrencyUSDQuoteUseCase(quotesRepository)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideIsHighNetworkFeeUseCase(
|
||||
getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase,
|
||||
): IsHighNetworkFeeUseCase {
|
||||
return IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
|
|
@ -10,22 +11,15 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
implementation(projects.domain.search)
|
||||
implementation(projects.domain.common)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.markets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.appCurrency)
|
||||
// region Other libraries
|
||||
api(deps.androidx.datastore)
|
||||
api(deps.moshi)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
|
|
@ -33,8 +27,15 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Project - Core
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.datasource)
|
||||
// endregion
|
||||
|
||||
// region Project - Domain
|
||||
api(projects.domain.account.status)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.search)
|
||||
implementation(projects.domain.models)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -5,38 +5,40 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.settings"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.settings)
|
||||
// region Kotlin
|
||||
implementation(deps.kotlin.coroutines)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.androidx.core)
|
||||
implementation(deps.androidx.datastore)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.settings)
|
||||
// endregion
|
||||
|
||||
// region Test
|
||||
testImplementation(projects.test.core)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Development implementation of [UsedeskTokenTtlManager].
|
||||
*
|
||||
* Reads and writes the Usedesk chat token TTL from [AppPreferencesStore],
|
||||
* allowing testers to override it via the Tester Menu.
|
||||
* The preference [kotlinx.coroutines.flow.Flow] is converted to a [StateFlow] on construction,
|
||||
* so [getTokenTtlMillisSync] can be called from non-suspending contexts.
|
||||
* Defaults to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] when no value is stored.
|
||||
*/
|
||||
internal class DevUsedeskTokenTtlManager(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : UsedeskTokenTtlManager {
|
||||
|
||||
private val ttlMillisState: StateFlow<Long> =
|
||||
appPreferencesStore
|
||||
.get(key = USEDESK_TOKEN_TTL_MILLIS_KEY, default = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
.stateIn(
|
||||
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS,
|
||||
)
|
||||
|
||||
override fun getTokenTtlMillis(): StateFlow<Long> = ttlMillisState
|
||||
|
||||
override fun getTokenTtlMillisSync(): Long = ttlMillisState.value
|
||||
|
||||
override suspend fun setTokenTtlMillis(millis: Long) {
|
||||
appPreferencesStore.editData { preferences ->
|
||||
preferences[USEDESK_TOKEN_TTL_MILLIS_KEY] = millis
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USEDESK_TOKEN_TTL_MILLIS_KEY = longPreferencesKey(name = "usedeskTokenTtlMillis")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Production implementation of [UsedeskTokenTtlManager].
|
||||
*
|
||||
* The Usedesk chat token TTL is fixed to [UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS] in production —
|
||||
* a tester override must never leak into release builds.
|
||||
* [setTokenTtlMillis] is a no-op since the TTL cannot be changed at runtime.
|
||||
*/
|
||||
internal class ProdUsedeskTokenTtlManager : UsedeskTokenTtlManager {
|
||||
|
||||
private val state: StateFlow<Long> = MutableStateFlow(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
|
||||
override fun getTokenTtlMillis(): StateFlow<Long> = state
|
||||
override fun getTokenTtlMillisSync(): Long = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
|
||||
override suspend fun setTokenTtlMillis(millis: Long) = Unit
|
||||
}
|
||||
|
|
@ -6,11 +6,14 @@ import com.tangem.data.settings.DefaultAppRatingRepository
|
|||
import com.tangem.data.settings.DefaultPermissionRepository
|
||||
import com.tangem.data.settings.DefaultSettingsRepository
|
||||
import com.tangem.data.settings.DevHotWalletRestrictionManager
|
||||
import com.tangem.data.settings.DevUsedeskTokenTtlManager
|
||||
import com.tangem.data.settings.ProdHotWalletRestrictionManager
|
||||
import com.tangem.data.settings.ProdUsedeskTokenTtlManager
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.settings.HotWalletRestrictionManager
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.domain.settings.repositories.AppRatingRepository
|
||||
import com.tangem.domain.settings.repositories.PermissionRepository
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
|
|
@ -72,4 +75,17 @@ internal object SettingsDataModule {
|
|||
ProdHotWalletRestrictionManager()
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsedeskTokenTtlManager(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): UsedeskTokenTtlManager {
|
||||
return if (BuildConfig.TESTER_MENU_ENABLED) {
|
||||
DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
} else {
|
||||
ProdUsedeskTokenTtlManager()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.utils.get
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DevUsedeskTokenTtlManagerTest {
|
||||
|
||||
private val appPreferencesStore = mockk<AppPreferencesStore>(relaxed = true)
|
||||
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored ttl WHEN getTokenTtlMillis THEN emits stored value`() = runTest {
|
||||
// Arrange
|
||||
val storedTtl = 15L * 60 * 1000
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(storedTtl)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act
|
||||
val emitted = getEmittedValues(manager.getTokenTtlMillis())
|
||||
|
||||
// Assert
|
||||
assertThat(emitted).containsExactly(storedTtl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no stored ttl WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
|
||||
// Arrange
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(DEFAULT)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act & Assert
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(DEFAULT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored ttl WHEN getTokenTtlMillisSync THEN returns cached value`() = runTest {
|
||||
// Arrange
|
||||
val storedTtl = 60L * 60 * 1000
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(storedTtl)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act & Assert
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(storedTtl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN setTokenTtlMillis THEN opens editData transaction`() = runTest {
|
||||
// Arrange
|
||||
every {
|
||||
appPreferencesStore.get(key = any<Preferences.Key<Long>>(), default = DEFAULT)
|
||||
} returns flowOf(DEFAULT)
|
||||
coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true)
|
||||
|
||||
val manager = DevUsedeskTokenTtlManager(appPreferencesStore, dispatchers)
|
||||
|
||||
// Act
|
||||
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
|
||||
|
||||
// Assert
|
||||
coVerify { appPreferencesStore.editData(any()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DEFAULT = UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.settings
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.settings.UsedeskTokenTtlManager
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class ProdUsedeskTokenTtlManagerTest {
|
||||
|
||||
private val manager = ProdUsedeskTokenTtlManager()
|
||||
|
||||
@Test
|
||||
fun `WHEN getTokenTtlMillis THEN always emits default`() = runTest {
|
||||
val emitted = getEmittedValues(manager.getTokenTtlMillis())
|
||||
|
||||
assertThat(emitted).containsExactly(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN getTokenTtlMillisSync THEN returns default`() = runTest {
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN override attempt WHEN setTokenTtlMillis THEN sync still returns default`() = runTest {
|
||||
manager.setTokenTtlMillis(millis = 15L * 60 * 1000)
|
||||
|
||||
assertThat(manager.getTokenTtlMillisSync()).isEqualTo(UsedeskTokenTtlManager.DEFAULT_TTL_MILLIS)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,66 +5,76 @@ plugins {
|
|||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.staking"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core modules */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Common modules */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain modules */
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.staking)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.walletManager)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Feature Api modules */
|
||||
implementation(projects.features.staking.api)
|
||||
|
||||
// region DI
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Kotlin
|
||||
api(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.datetime)
|
||||
// endregion
|
||||
|
||||
// region Others dependencies
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.datetime)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
// region Other libraries
|
||||
api(deps.androidx.datastore)
|
||||
api(deps.moshi)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.firebase.crashlytics)
|
||||
ksp(deps.moshi.kotlin.codegen)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
// region Tangem SDK
|
||||
implementation(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
|
||||
implementation(tangemDeps.card.core)
|
||||
// endregion
|
||||
|
||||
testImplementation(tangemDeps.card.core)
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Core modules
|
||||
api(projects.core.analytics)
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.datasource)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.analytics.models)
|
||||
// endregion
|
||||
|
||||
// region Data
|
||||
implementation(projects.data.common)
|
||||
// endregion
|
||||
|
||||
// region Domain
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.staking)
|
||||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.card)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
api(projects.domain.staking.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
implementation(projects.libs.crypto)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ internal class DefaultStakingFeatureToggles(
|
|||
}
|
||||
|
||||
private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) {
|
||||
is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED
|
||||
is StakingIntegrationID.P2PEthPool -> null
|
||||
is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package com.tangem.data.staking.toggles
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.domain.staking.model.StakingIntegrationID
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -24,21 +24,10 @@ internal class DefaultStakingFeatureTogglesTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() {
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true
|
||||
|
||||
fun `P2PEthPool integration is always enabled`() {
|
||||
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue()
|
||||
|
||||
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() {
|
||||
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false
|
||||
|
||||
assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse()
|
||||
|
||||
verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) }
|
||||
verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue